Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b66eff809 | ||
|
|
d773fa91df | ||
|
|
3804ef7556 | ||
|
|
7d202a34dc | ||
|
|
ded26a658c | ||
|
|
4c9a1af719 | ||
|
|
f4d611b77c | ||
|
|
cf265e8f66 | ||
|
|
55d9ca6eee | ||
|
|
6b1b9c6af4 | ||
|
|
4634048eab | ||
|
|
ceaa7d18d8 | ||
|
|
75e8e91f9a | ||
|
|
de78fb195f | ||
|
|
2a6bb8e8af | ||
|
|
f34affa071 | ||
|
|
613a288dea | ||
|
|
c21a3c50f0 | ||
|
|
0030334e5e | ||
|
|
5584ddf397 | ||
|
|
736cdb42fc | ||
|
|
903c948d85 |
@@ -12,6 +12,10 @@
|
|||||||
# chosen owner and to push to the new one. Prefer a fine-grained token.
|
# chosen owner and to push to the new one. Prefer a fine-grained token.
|
||||||
GITHUB_PAT=
|
GITHUB_PAT=
|
||||||
|
|
||||||
|
########################################
|
||||||
|
# Secrets for workframe
|
||||||
|
########################################
|
||||||
|
|
||||||
# GitHub account the token belongs to. It identifies who authenticates; it is
|
# GitHub account the token belongs to. It identifies who authenticates; it is
|
||||||
# only a default suggestion for the owner prompt, because the repository can
|
# only a default suggestion for the owner prompt, because the repository can
|
||||||
# belong to an organization.
|
# belong to an organization.
|
||||||
|
|||||||
@@ -184,3 +184,7 @@ cython_debug/
|
|||||||
*~
|
*~
|
||||||
tmp/
|
tmp/
|
||||||
repofoundry.*/
|
repofoundry.*/
|
||||||
|
|
||||||
|
|
||||||
|
!src/lib
|
||||||
|
config.env
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
# shellcheck configuration for the RepoFoundry scripts
|
# shellcheck configuration for the RepoFoundry scripts
|
||||||
shell=bash
|
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,417 @@
|
|||||||
# repo_foundry
|
# Repo Foundry
|
||||||
|
|
||||||
|
RepoFoundry (`src/create-project.sh`) sets up a new project in one run:
|
||||||
|
|
||||||
|
- a **Gitea** repository (the source of truth),
|
||||||
|
- optionally an empty **GitHub** repository that receives everything through a
|
||||||
|
**push mirror from Gitea to GitHub**,
|
||||||
|
- and a **local project** with credential-free remotes and the
|
||||||
|
[SQA-QC-Framework](https://git.tirsystem.com/TirSystem/SQA-QC-Framework)
|
||||||
|
added as a git submodule, with its skills, git hooks (and optionally the plan
|
||||||
|
gate) and templates installed.
|
||||||
|
|
||||||
|
It is a Bash script. It asks for the repository name, description, visibility
|
||||||
|
and owner (a user or an organization, separately on each host), shows a plan,
|
||||||
|
and only creates anything after you pass `--apply` and answer yes.
|
||||||
|
|
||||||
|
> **Status.** The script is tested with stubbed host APIs and real git against
|
||||||
|
> local repositories (see [Development](#development)). A first end-to-end run
|
||||||
|
> on real GitHub and Gitea repositories, with organization owners on both, has
|
||||||
|
> passed (2026-10-05, review record RC-017). Creating a repository under your
|
||||||
|
> own Gitea account needs the `write:user` token scope (see
|
||||||
|
> [Token permissions](#token-permissions)); that path has not been completed
|
||||||
|
> yet.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
1. [Installation](#installation)
|
||||||
|
2. [Configuration](#configuration)
|
||||||
|
3. [Usage](#usage)
|
||||||
|
4. [SSH access to Gitea](#ssh-access-to-gitea)
|
||||||
|
5. [Token permissions](#token-permissions)
|
||||||
|
6. [Security decisions](#security-decisions)
|
||||||
|
7. [Error handling and recovery](#error-handling-and-recovery)
|
||||||
|
8. [Known limitations](#known-limitations)
|
||||||
|
9. [Code layout](#code-layout)
|
||||||
|
10. [Development](#development)
|
||||||
|
11. [Stakeholders](#stakeholders)
|
||||||
|
12. [License](#license)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
| Tool | Needed for |
|
||||||
|
| --- | --- |
|
||||||
|
| bash 4.4 or later | the script (macOS ships 3.2: install a newer bash first) |
|
||||||
|
| `git` | the local project and the framework submodule |
|
||||||
|
| `curl` | the GitHub and Gitea APIs |
|
||||||
|
| `mktemp` and the usual base tools | temporary files and small helpers |
|
||||||
|
| `ssh` (optional) | the SSH check; without it the framework steps are skipped |
|
||||||
|
| `jq` (optional) | JSON parsing; without it a small built-in reader is used |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.tirsystem.com/TirSystem-BashScript/RepoFoundry.git
|
||||||
|
cd RepoFoundry
|
||||||
|
src/create-project.sh --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing has to be installed system-wide: the script runs from the checkout and
|
||||||
|
loads its own files from `src/lib/`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The script reads two plain files from the project root. They are **parsed,
|
||||||
|
never executed** (`source` is not used): only `KEY=VALUE` lines with known keys
|
||||||
|
are accepted, and anything else stops the run with a message that names the key
|
||||||
|
and the line, never the value.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.env.example config.env # service addresses, not secret: set GITEA_URL (and GITEA_API_URL)
|
||||||
|
cp .env.example .env # credentials: keep private
|
||||||
|
chmod 600 .env # Linux and macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
### `config.env` (service addresses)
|
||||||
|
|
||||||
|
| Key | Meaning | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GITHUB_API_URL` | GitHub REST API base URL | `https://api.github.com` |
|
||||||
|
| `GITHUB_WEB_URL` | GitHub web base URL (links and the mirror address) | `https://github.com` |
|
||||||
|
| `GITEA_URL` | Gitea base URL (required) | |
|
||||||
|
| `GITEA_API_URL` | Gitea REST API base URL | `GITEA_URL` + `/api/v1` |
|
||||||
|
| `GITEA_SSH_PORT` | SSH port of the Gitea server | `10022` |
|
||||||
|
| `MIRROR_INTERVAL` | how often Gitea pushes to GitHub, e.g. `10m0s` | `10m0s` |
|
||||||
|
| `FRAMEWORK_REPO` | `OWNER/NAME` of the framework on Gitea | `TirSystem/SQA-QC-Framework` |
|
||||||
|
|
||||||
|
Every URL must start with `https://` and must not contain a user name,
|
||||||
|
password, query string or fragment. A credential key in this file is rejected.
|
||||||
|
|
||||||
|
### `config.env` (project details, optional)
|
||||||
|
|
||||||
|
Any of the details the script asks for can be set in `config.env` instead.
|
||||||
|
A detail that is set is used and not asked; the summary marks it with
|
||||||
|
`(from config.env)`.
|
||||||
|
|
||||||
|
| Key | Detail | Accepted value |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PROJECT_NAME` | repository name | letters, digits, `.`, `_`, `-`; at most 100; not ending in `.git` |
|
||||||
|
| `PROJECT_DESCRIPTION` | description | at most 350 characters; may be empty |
|
||||||
|
| `PROJECT_VISIBILITY` | visibility | `private` or `public` |
|
||||||
|
| `GITEA_OWNER` | Gitea user or organization | letters, digits, `.`, `_`, `-`; at most 39 |
|
||||||
|
| `USE_GITHUB` | also create a GitHub repository | `yes` or `no` |
|
||||||
|
| `GITHUB_OWNER` | GitHub user or organization | letters, digits, `-`; used only when GitHub is used |
|
||||||
|
| `PROJECT_DIRECTORY` | local directory | not empty, not starting with `-` |
|
||||||
|
| `ENABLE_PLAN_GATE` | enable the plan gate | `yes` or `no` |
|
||||||
|
|
||||||
|
- A key that is present counts as set, even when its value is empty. Only
|
||||||
|
`PROJECT_DESCRIPTION` may be empty (no description); an empty value for any
|
||||||
|
other key stops the run. Remove the line to be asked instead.
|
||||||
|
- An invalid value stops the run before any request to a host and names the
|
||||||
|
key. The script never falls back to asking for it.
|
||||||
|
- `USE_GITHUB=no` skips the GitHub owner and every GitHub step; a
|
||||||
|
`GITHUB_OWNER` set at the same time is ignored, with a warning.
|
||||||
|
- Only these eight details can be set. The confirmations stay questions that
|
||||||
|
default to no: create now, reusing an existing repository, an existing
|
||||||
|
directory, `core.hooksPath` and replacing a template file.
|
||||||
|
- These keys are accepted in `config.env` only, never in `.env`.
|
||||||
|
- A value is read as plain text: an unquoted ` #` starts a comment and cuts the
|
||||||
|
value there. Put a description that contains ` #` in double quotes, for
|
||||||
|
example `PROJECT_DESCRIPTION="Tool for #mirrors"`.
|
||||||
|
|
||||||
|
With all eight set, a run asks only the confirmations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
src/create-project.sh --apply # asks only "Create these now (y/n) [n]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `.env` (credentials)
|
||||||
|
|
||||||
|
| Key | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `GITEA_TOKEN` | Gitea access token (required) |
|
||||||
|
| `GITHUB_PAT` | GitHub personal access token (only when you choose GitHub) |
|
||||||
|
| `GITHUB_USER` | the GitHub account the token belongs to; only a default for the owner prompt |
|
||||||
|
|
||||||
|
`.env` is ignored by git. The script warns if it is readable by other users or
|
||||||
|
not ignored by git. See [Token permissions](#token-permissions) for what each
|
||||||
|
token needs.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
src/create-project.sh # dry run: reads from the hosts, creates nothing
|
||||||
|
src/create-project.sh --apply # creates everything after a final yes
|
||||||
|
src/create-project.sh --config /path/to/config.env --env /path/to/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
The script asks for, in this order: repository name, description, visibility,
|
||||||
|
Gitea owner, whether to also create a GitHub repository (and its owner), the
|
||||||
|
local directory and whether to enable the plan gate (a detail set in
|
||||||
|
[`config.env`](#configenv-project-details-optional) is not asked). It then checks both hosts
|
||||||
|
with read-only requests and prints a plan:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Plan:
|
||||||
|
Gitea repository : create (private) with the AGPL-3.0 license https://git.example.org/Team/my-app
|
||||||
|
GitHub repository : create (private), empty https://github.com/acme/my-app
|
||||||
|
Push mirror : Gitea -> GitHub every 10m0s
|
||||||
|
Local project : create ./my-app (new directory), git on main, no commit
|
||||||
|
Local origin : will use SSH (the SSH test passed)
|
||||||
|
Framework : add ssh://git@git.example.org:10022/Team/SQA-QC-Framework.git as a submodule
|
||||||
|
Skills and hooks : install once; plan gate no
|
||||||
|
Templates : AGENTS.md and docs/artifact-registry.md (you are asked before a file is replaced)
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `--apply` that is all that happens. With `--apply` the script asks
|
||||||
|
"Create these now" (default no) and then creates, in this order:
|
||||||
|
|
||||||
|
1. the GitHub repository (empty), if chosen;
|
||||||
|
2. the Gitea repository (with the AGPL-3.0 license if GitHub was chosen);
|
||||||
|
3. the push mirror Gitea -> GitHub, and a request for its first sync;
|
||||||
|
4. the local directory, `git init` on `main`, the `origin` remote (and `github`
|
||||||
|
if chosen), and, if the Gitea repository holds the license commit, that
|
||||||
|
history;
|
||||||
|
5. the framework as the submodule `framework`;
|
||||||
|
6. the framework's skills and git hooks, and the plan gate if chosen;
|
||||||
|
7. `AGENTS.md` and `docs/artifact-registry.md` from the framework's templates.
|
||||||
|
|
||||||
|
No commit is made in the new project. Work on a branch there: the framework's
|
||||||
|
hooks refuse commits on `main`.
|
||||||
|
|
||||||
|
### Choices
|
||||||
|
|
||||||
|
- **GitHub or not.** Choosing GitHub also applies the AGPL-3.0 license to the
|
||||||
|
Gitea repository (so it is not empty) and sets up the mirror. Without GitHub
|
||||||
|
the Gitea repository is empty and has no license, and `GITHUB_PAT` is not
|
||||||
|
needed.
|
||||||
|
- **Owners.** The Gitea owner and the GitHub owner are chosen separately and
|
||||||
|
may be a user or an organization. `GITHUB_USER` is only the suggested default
|
||||||
|
for the GitHub owner prompt; it identifies who authenticates.
|
||||||
|
- **Plan gate.** If enabled, a commit that changes `src/` or `tests/` in the
|
||||||
|
new project needs a `Task: MIL-NNN#N` trailer.
|
||||||
|
|
||||||
|
### Nothing is overwritten without a yes
|
||||||
|
|
||||||
|
The script asks first (default no) before it uses an existing directory, before
|
||||||
|
it replaces an existing `core.hooksPath`, and before it replaces an existing
|
||||||
|
`AGENTS.md` or `docs/artifact-registry.md`. It never deletes anything, never
|
||||||
|
replaces a remote that points somewhere else, and git itself refuses to
|
||||||
|
overwrite a file when the license history is checked out.
|
||||||
|
|
||||||
|
## SSH access to Gitea
|
||||||
|
|
||||||
|
The framework submodule is fetched over SSH on port **10022**
|
||||||
|
(`ssh://git@<gitea host>:10022/TirSystem/SQA-QC-Framework.git`). Before you run
|
||||||
|
the script:
|
||||||
|
|
||||||
|
1. Add your SSH public key to your Gitea account.
|
||||||
|
2. Connect once by hand so that the server's host key is known (the script
|
||||||
|
refuses unknown host keys and never answers questions for you):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 10022 -T git@git.tirsystem.com
|
||||||
|
```
|
||||||
|
|
||||||
|
A message that you have successfully authenticated, without shell access,
|
||||||
|
means it works.
|
||||||
|
|
||||||
|
The script runs the same check in its dry run. If it fails, the plan says so
|
||||||
|
and, with `--apply`, you are asked whether to create the repositories and the
|
||||||
|
local project **without** the framework steps (they are then reported as
|
||||||
|
skipped). The default answer is no.
|
||||||
|
|
||||||
|
## 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 | worked with a classic token that has `repo` and `admin:org` | `read:org` alone was **not tested**: 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`:** 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 a repository **under your own account** | `write:user` | **Confirmed by a real server**: without it Gitea answers `required=[write:user]` |
|
||||||
|
| Create a repository in an organization, manage its push mirror | `write:organization` and `write:repository` | worked with a token that has both, plus `read:user`; the minimum was not narrowed down |
|
||||||
|
| Look up an organization and your permissions in it | covered by the scopes above | worked in the end-to-end run |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Security decisions
|
||||||
|
|
||||||
|
- **Tokens never appear** in output, logs, remote URLs, `.git/config`,
|
||||||
|
`.gitmodules`, command lines or leftover files. They go to `curl` through a
|
||||||
|
private configuration file that is removed right after the request, and to
|
||||||
|
`git` (HTTPS fetch only) through a `GIT_ASKPASS` helper and the environment
|
||||||
|
of that one command. Output is filtered, so even a server message that echoes
|
||||||
|
a token is shown as `[redacted]`. Tests plant fake tokens and search all
|
||||||
|
output and every file of the new project for them.
|
||||||
|
- **No `set -x`.** Tracing would print every secret, so the script switches it
|
||||||
|
off and says so.
|
||||||
|
- **Config files are parsed, not sourced,** with a whitelist of keys; values
|
||||||
|
are validated (URLs must be `https` without credentials, tokens must have a
|
||||||
|
safe character set) and never executed.
|
||||||
|
- **Dry run by default.** Creating anything needs `--apply` and a final yes.
|
||||||
|
- **No destructive commands.** The script never deletes a repository or a
|
||||||
|
file and never uses a recursive delete; temporary files are removed one by
|
||||||
|
one.
|
||||||
|
- **Credential-free remotes.** `origin` is `ssh://git@host:port/owner/name.git`
|
||||||
|
(or plain HTTPS when SSH is not used) and `github` is a plain HTTPS address.
|
||||||
|
- **Redirects are not followed,** so a token is only ever sent to the host in
|
||||||
|
the URL it was meant for. Unknown SSH host keys are refused.
|
||||||
|
- **Framework scripts run on the new project only.** They are run with
|
||||||
|
`PROJECT_ROOT` set explicitly, so a `PROJECT_ROOT` in your environment cannot
|
||||||
|
point them elsewhere. They come from the framework repository you configured:
|
||||||
|
review what you trust there.
|
||||||
|
|
||||||
|
## Error handling and recovery
|
||||||
|
|
||||||
|
Every message starts with `error:`, names what failed and what to do, and never
|
||||||
|
contains a secret. Exit codes: `0` success (or a dry run), `1` a failed check or
|
||||||
|
step, `2` a usage error.
|
||||||
|
|
||||||
|
| What happens | What the script does | What you do |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A tool, a config key or a token is missing or invalid | stops before any request | fix it and run again |
|
||||||
|
| A token is refused, an owner is unknown, a name is taken, the license is missing | stops in the preflight; nothing was created | fix the cause |
|
||||||
|
| The host cannot be reached | stops with the host name | try again |
|
||||||
|
| A repository already exists and is empty (Gitea: or holds only the license and the README Gitea adds) | offers to reuse it (default no) | answer, or choose another name |
|
||||||
|
| A repository already has content | stops | choose another name or remove it |
|
||||||
|
| A step fails after another succeeded | stops and prints what exists, what failed and how to continue | fix the cause and run the **same command again with `--apply`**: what was created is offered for reuse |
|
||||||
|
| The mirror is refused (disabled, interval too short) | keeps the repositories and reports it | change `MIRROR_INTERVAL` or ask the Gitea administrator, then run again |
|
||||||
|
| The framework submodule cannot be fetched | reports the address and how to test SSH | fix your SSH access, run again |
|
||||||
|
| `sync_on_commit` was ignored by Gitea | warns; the mirror syncs on its interval | enable it in the repository settings if needed |
|
||||||
|
|
||||||
|
A partial run is reported like this:
|
||||||
|
|
||||||
|
```text
|
||||||
|
The run stopped before it finished. This is what exists now:
|
||||||
|
GitHub repository : created https://github.com/acme/my-app
|
||||||
|
Gitea repository : FAILED
|
||||||
|
Push mirror : not attempted
|
||||||
|
...
|
||||||
|
To continue: fix the problem named above and run the same command again with --apply.
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing is deleted automatically. To start over, delete the repositories in the
|
||||||
|
web interface and the project directory by hand.
|
||||||
|
|
||||||
|
## 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). On Gitea 1.27.3 it was applied: a branch pushed to
|
||||||
|
Gitea reached GitHub within seconds. 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`, and on the real server it also adds a generated
|
||||||
|
`README.md`. Both are mirrored to GitHub and become the first commit of the
|
||||||
|
local project. A Gitea repository that holds only these files counts as
|
||||||
|
content this script created and is offered for reuse; a repository with
|
||||||
|
anything else counts as having content and is refused.
|
||||||
|
- **No rollback.** See [Error handling and recovery](#error-handling-and-recovery).
|
||||||
|
- **Mirror direction is Gitea to GitHub only.** Push to Gitea; GitHub is a
|
||||||
|
copy.
|
||||||
|
- **The framework needs SSH.** Without SSH access to Gitea the framework steps
|
||||||
|
can only be skipped.
|
||||||
|
- **Tested on Windows (Git Bash) only so far.** Running the tests on Linux and
|
||||||
|
macOS is an open follow-up.
|
||||||
|
|
||||||
|
## 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, links and remote addresses of the repositories |
|
||||||
|
| `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 |
|
||||||
|
| `git.sh` | running git for the new project without prompts or tokens on a command line |
|
||||||
|
| `localproject.sh` | the local directory, git repository and remotes |
|
||||||
|
| `framework.sh` | the framework submodule, skills, hooks and templates |
|
||||||
|
| `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
|
||||||
|
bash tests/run-tests.sh PATTERN # only tests whose name contains PATTERN
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests stub the two host APIs (a fake `curl` answers from a routes file) and
|
||||||
|
`ssh`, and run real git against local bare repositories that stand in for
|
||||||
|
Gitea and the framework (git's `insteadOf` rewrites the remote addresses), with
|
||||||
|
a private git configuration. Nothing reaches the network and nothing outside
|
||||||
|
the test directories is changed. Mutation checks show that the tests fail when
|
||||||
|
a guarantee is removed.
|
||||||
|
|
||||||
|
Planning documents, reviews and the traceability matrix are in `docs/`; the
|
||||||
|
project follows the SQA and QC framework (see `AGENTS.md`).
|
||||||
|
|
||||||
|
## Stakeholders
|
||||||
|
|
||||||
|
| Who | Role |
|
||||||
|
| --- | --- |
|
||||||
|
| [Tirsvad](https://www.linkedin.com/in/tirsvad74) | Product Owner and maintainer |
|
||||||
|
| [Michael Kragh](https://www.linkedin.com/in/codemikemike/) | DevOps, cybersecurity and maintainer |
|
||||||
|
| GitHub readers | people who read and may reuse this project |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GNU Affero General Public License v3.0; see [LICENSE](LICENSE).
|
||||||
|
|||||||
+31
-2
@@ -17,7 +17,36 @@ GITHUB_API_URL=https://api.github.com
|
|||||||
GITHUB_WEB_URL=https://github.com
|
GITHUB_WEB_URL=https://github.com
|
||||||
|
|
||||||
# Gitea instance base URL. Repository links are derived from it.
|
# Gitea instance base URL. Repository links are derived from it.
|
||||||
GITEA_URL=https://git.tirsystem.com/
|
GITEA_URL=https://<your gitea instance>/
|
||||||
|
|
||||||
# Gitea REST API base URL. If you leave this out it is GITEA_URL + /api/v1.
|
# 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
|
GITEA_API_URL=https://<your gitea instance>/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
|
||||||
|
|
||||||
|
# Optional. Project details that are set here are not asked. A key that is
|
||||||
|
# present counts as set, even when empty; only PROJECT_DESCRIPTION may be
|
||||||
|
# empty. An invalid value stops the run and names the key. Remove or comment
|
||||||
|
# out a line to be asked for it. The confirmations ("Create these now" and
|
||||||
|
# the questions about existing repositories, directories and files) are
|
||||||
|
# always asked. Put a value that contains " #" in double quotes: an unquoted
|
||||||
|
# " #" starts a comment.
|
||||||
|
#PROJECT_NAME=my-project
|
||||||
|
#PROJECT_DESCRIPTION=What the project is for
|
||||||
|
#PROJECT_VISIBILITY=private # private or public
|
||||||
|
#GITEA_OWNER=my-organization
|
||||||
|
#USE_GITHUB=yes # yes or no
|
||||||
|
#GITHUB_OWNER=my-organization # used only when USE_GITHUB is yes
|
||||||
|
#PROJECT_DIRECTORY=./my-project
|
||||||
|
#ENABLE_PLAN_GATE=no # yes or no
|
||||||
|
|
||||||
|
# Optional. OWNER/NAME of the SQA-QC-Framework repository on the Gitea server;
|
||||||
|
# it is added to the new project as a submodule over SSH.
|
||||||
|
# Default: TirSystem/SQA-QC-Framework.
|
||||||
|
#FRAMEWORK_REPO=TirSystem/SQA-QC-Framework
|
||||||
|
|||||||
@@ -14,16 +14,17 @@ document of a type. `Primary File` may contain a glob (e.g.
|
|||||||
| BC | Business Case | docs/business-case.md | 002 |
|
| BC | Business Case | docs/business-case.md | 002 |
|
||||||
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
||||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 004 |
|
| MIL | Milestone / Gateway | docs/milestones/*.md | 007 |
|
||||||
| US | User Story | docs/user-stories.md | 002 |
|
| US | User Story | docs/user-stories.md | 002 |
|
||||||
| UC | Use Case | docs/uc-*/uc.md | 002 |
|
| UC | Use Case | docs/uc-*/uc.md | 002 |
|
||||||
| SSD | System Sequence Diagram | docs/uc-*/ssd.md | 002 |
|
| SSD | System Sequence Diagram | docs/uc-*/ssd.md | 002 |
|
||||||
| OC | Operation Contract | docs/uc-*/oc.md | 002 |
|
| OC | Operation Contract | docs/uc-*/oc.md | 002 |
|
||||||
| SD | Sequence Diagram | docs/uc-*/sd.md | 002 |
|
| SD | Sequence Diagram | docs/uc-*/sd.md | 002 |
|
||||||
| DM | Domain Model | docs/domain-model.md | 003 |
|
| DM | Domain Model | docs/domain-model.md | 003 |
|
||||||
|
| DCD | Design Class Diagram | docs/dcd.md | 003 |
|
||||||
| DICT | Domain Dictionary (PO and IT terms) | docs/dictionary.md | 002 |
|
| DICT | Domain Dictionary (PO and IT terms) | docs/dictionary.md | 002 |
|
||||||
| UCD | Use Case Diagram | docs/use-case-diagram.md | 002 |
|
| UCD | Use Case Diagram | docs/use-case-diagram.md | 002 |
|
||||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 017 |
|
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 022 |
|
||||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||||
|
|
||||||
## Languages
|
## Languages
|
||||||
|
|||||||
+19
-8
@@ -9,8 +9,8 @@
|
|||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-05 | Deprecated | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
| 2026-10-06 | Accepted | Jens Tirsvad Nielsen | S02 | Added objective 9 (credentials asked, project .env created), scope items, success criterion 9 and a risk<br>Objective 6 and success criterion 1 now allow a token only in the new project's .env | [ded26a6] |
|
||||||
| 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] |
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | Added objective 10 (PROJECT_LICENSE in config.env), a scope item and success criterion 10; objective 2 now names the configured license | [d773fa9] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,12 +35,15 @@ One repeatable, reviewed procedure gives every new project the same secure basel
|
|||||||
## Objectives
|
## Objectives
|
||||||
|
|
||||||
1. Optionally create an empty GitHub repository under a chosen user or organization.
|
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.
|
2. Create a Gitea repository under a chosen user or organization, empty, or with a license: the one set in `config.env` (`PROJECT_LICENSE`), or AGPL-3.0 when GitHub is chosen and none is set.
|
||||||
3. When GitHub was chosen, configure the Gitea repository as a push mirror to GitHub (direction Gitea to GitHub).
|
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.
|
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.
|
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.
|
6. Never print a token or put one in a URL, a remote or a log, write one to disk only in the new project's own `.env` and only after the Maintainer agrees, 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.
|
7. Document installation, configuration, usage, security decisions and error handling in clear English for GitHub readers.
|
||||||
|
8. Let the Maintainer preset the project details in `config.env`, so that a detail that is set there is not asked again.
|
||||||
|
9. Ask for a credential that is not provided in `.env` (`GITEA_TOKEN`, `GITHUB_PAT`, `GITHUB_USER`) and, when the Maintainer agrees, create a `.env` file with the credentials the new project needs.
|
||||||
|
10. Let the Maintainer set the project's license in `config.env` (`PROJECT_LICENSE`), independent of the GitHub choice, or set `none` for no license.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -48,9 +51,11 @@ One repeatable, reviewed procedure gives every new project the same secure basel
|
|||||||
|
|
||||||
- `create-project.sh`, `config.env.example`, `.env.example`, `.gitignore` and `README.md`.
|
- `create-project.sh`, `config.env.example`, `.env.example`, `.gitignore` and `README.md`.
|
||||||
- Safe parsing and validation of `config.env` and `.env` (never `source`d).
|
- Safe parsing and validation of `config.env` and `.env` (never `source`d).
|
||||||
- Prompts for name, description, visibility and owner on each chosen host, and whether to use GitHub (which also applies the AGPL license).
|
- Prompts for name, description, visibility and owner on each chosen host, and whether to use GitHub (which also applies the AGPL license). Each of these details may be set in `config.env` instead and is then not asked.
|
||||||
- Checks for required tools (`git`, `curl`, optional `jq`) before any change.
|
- Checks for required tools (`git`, `curl`, optional `jq`) before any change.
|
||||||
- A check that the project name is not already taken on GitHub.
|
- A check that the project name is not already taken on GitHub.
|
||||||
|
- Asking for a credential that `.env` does not provide, and creating the new project's own `.env` (owner-only, ignored by git, never overwritten without a yes).
|
||||||
|
- A project license set in `config.env` (`PROJECT_LICENSE`, optional, never asked), checked against the licenses the Gitea server offers.
|
||||||
- Partial-failure reporting with a documented way to continue.
|
- Partial-failure reporting with a documented way to continue.
|
||||||
- Documentation of the SSH prerequisite for the submodule (Gitea SSH on port `10022`).
|
- Documentation of the SSH prerequisite for the submodule (Gitea SSH on port `10022`).
|
||||||
|
|
||||||
@@ -60,6 +65,7 @@ One repeatable, reviewed procedure gives every new project the same secure basel
|
|||||||
- Managing repositories after creation (branch protection, webhooks, teams, CI).
|
- Managing repositories after creation (branch protection, webhooks, teams, CI).
|
||||||
- Hosts other than GitHub and the configured Gitea instance.
|
- Hosts other than GitHub and the configured Gitea instance.
|
||||||
- Creating or rotating tokens and SSH keys.
|
- Creating or rotating tokens and SSH keys.
|
||||||
|
- Storing a credential anywhere but the new project's `.env` (no password manager, keychain or encryption).
|
||||||
- Making the first commit or opening a pull request.
|
- Making the first commit or opening a pull request.
|
||||||
|
|
||||||
## Expected Benefits
|
## Expected Benefits
|
||||||
@@ -82,13 +88,16 @@ Supports developing on self-hosted Gitea while publishing to GitHub, and adoptin
|
|||||||
|
|
||||||
| # | Criterion | Target | Measure |
|
| # | 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 |
|
| 1 | Credential exposure | 0 occurrences of a token in output, saved remote URLs, tracked files, config files or leftover temp files; a token is written only to the new project's `.env` (owner-only, ignored by git) and only after a yes | Test run with log review; `git config --get-regexp remote` inspected; every file of the new project searched for the tokens |
|
||||||
| 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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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` |
|
| 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 |
|
| 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 |
|
||||||
|
| 8 | Preset details | A project detail set in `config.env` is never asked; an invalid one stops the run before any request and names the key | Tests with each key set, absent, empty and invalid |
|
||||||
|
| 9 | Credentials asked and kept | A credential missing from `.env` is asked (not echoed) instead of stopping the run; the new project's `.env` is created only after a yes, owner-only, ignored by git, holding only the keys the project needs, and an existing `.env` is never replaced without a yes | Tests: each credential present and missing, `.env` written, declined, existing, file mode, git exclusion, no token in output |
|
||||||
|
| 10 | Project license | `PROJECT_LICENSE` set: that license is on the Gitea repository with and without GitHub; `none`: no license; absent: AGPL-3.0 only when GitHub is chosen; a license the server does not offer stops the run before anything is created | Tests with a license set, `none`, absent and not offered |
|
||||||
|
|
||||||
## Risks
|
## Risks
|
||||||
|
|
||||||
@@ -98,6 +107,7 @@ Supports developing on self-hosted Gitea while publishing to GitHub, and adoptin
|
|||||||
| GitHub PAT lacks permission to create repositories or to push | Creation or mirroring fails | Document the required scopes; check with a read-only API call first and stop with a clear message |
|
| GitHub PAT lacks permission to create repositories or to push | Creation or mirroring fails | Document the required scopes; check with a read-only API call first and stop with a clear message |
|
||||||
| Gitea stores the mirror credentials server-side | A Gitea admin could access the GitHub token | Document it; recommend a fine-grained PAT limited to the one repository where possible |
|
| Gitea stores the mirror credentials server-side | A Gitea admin could access the GitHub token | Document it; recommend a fine-grained PAT limited to the one repository where possible |
|
||||||
| SSH to Gitea port `10022` is not configured | Submodule add fails after repositories already exist | Check SSH reachability before creating anything; document the prerequisite |
|
| SSH to Gitea port `10022` is not configured | Submodule add fails after repositories already exist | Check SSH reachability before creating anything; document the prerequisite |
|
||||||
|
| A token written to the new project's `.env` is plain text on disk and could be committed or copied by mistake | A leaked token gives access to the hosts | Ask first (default no), write only the keys the project needs, mode owner-only, exclude the file from git through `.git/info/exclude`, never overwrite an existing `.env` without a yes, never print the value, document the risk |
|
||||||
| Framework hook installer changes `core.hooksPath` | An existing hook setup is silently replaced | Inspect the current value first and ask for consent |
|
| Framework hook installer changes `core.hooksPath` | An existing hook setup is silently replaced | Inspect the current value first and ask for consent |
|
||||||
| Repository name conflicts on a host | Creation fails midway | Check availability on both hosts before creating either |
|
| Repository name conflicts on a host | Creation fails midway | Check availability on both hosts before creating either |
|
||||||
|
|
||||||
@@ -112,6 +122,7 @@ Supports developing on self-hosted Gitea while publishing to GitHub, and adoptin
|
|||||||
- Bash only, with `git` and `curl` required and `jq` optional.
|
- Bash only, with `git` and `curl` required and `jq` optional.
|
||||||
- `config.env` and `.env` are parsed, never `source`d.
|
- `config.env` and `.env` are parsed, never `source`d.
|
||||||
- No `rm -rf`, and no token in any URL, log or remote.
|
- No `rm -rf`, and no token in any URL, log or remote.
|
||||||
|
- A token on disk only in the new project's `.env`, created by the script with the Maintainer's consent.
|
||||||
- The framework under `framework/` is not edited from this project.
|
- The framework under `framework/` is not edited from this project.
|
||||||
|
|
||||||
## Cost–Benefit Assessment
|
## Cost–Benefit Assessment
|
||||||
@@ -138,5 +149,5 @@ Proceed — the procedure is small, well bounded and removes a repeated, securit
|
|||||||
|
|
||||||
[SA-001]: ./stakeholder-analysis.md
|
[SA-001]: ./stakeholder-analysis.md
|
||||||
[UCD-001]: ./use-case-diagram.md
|
[UCD-001]: ./use-case-diagram.md
|
||||||
[424f14f]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/424f14f4f5577bb47fea41c8f3a655dca953e6d8
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
[d773fa9]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/d773fa91df5a54090254e12e074880fb6526a9ff
|
||||||
|
|||||||
+338
@@ -0,0 +1,338 @@
|
|||||||
|
# Design Class Diagram
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | DCD-002 |
|
||||||
|
| CrossReference | [DCD-001], [DM-002], [UC-001], [OC-001], [SD-001], [DICT-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-06 | Accepted | Jens Tirsvad Nielsen | S02 | Added CredentialCollector, EnvFileWriter and EnvFile (from DCD-001) | [ded26a6] |
|
||||||
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | ProjectRequest carries the license that applies (from DCD-001) | [d773fa9] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose and Scope
|
||||||
|
|
||||||
|
The consolidated design 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" ([DCD-001]), which it was created from, and refines the concepts of [DM-002]. Class and attribute names are the IT terms of [DICT-001]. Every method traces to a contract in [OC-001] or a message in [SD-001].
|
||||||
|
|
||||||
|
The classes are design classes of a Bash program: a class is a group of functions in `src/lib/` with its data held in the shared state arrays (see "Implementation Mapping").
|
||||||
|
|
||||||
|
## Diagram
|
||||||
|
|
||||||
|
```plantuml
|
||||||
|
@startuml
|
||||||
|
skinparam classAttributeIconSize 0
|
||||||
|
hide empty members
|
||||||
|
|
||||||
|
enum Visibility {
|
||||||
|
private
|
||||||
|
public
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProjectCreator <<controller>> {
|
||||||
|
+startProjectCreation() : PromptSet
|
||||||
|
+provideProjectDetails(name : String, description : String, visibility : Visibility, giteaOwner : Owner, githubOwner : Owner [0..1], directory : Path, enablePlanGate : Boolean, writeEnvFile : Boolean) : Summary
|
||||||
|
}
|
||||||
|
class ConfigLoader {
|
||||||
|
+load(configFile : Path, envFile : Path) : Configuration
|
||||||
|
}
|
||||||
|
class CredentialCollector {
|
||||||
|
+collect(configuration : Configuration, kinds : String [1..3]) : Configuration
|
||||||
|
}
|
||||||
|
class EnvFileWriter {
|
||||||
|
+write(project : LocalProject, configuration : Configuration, hasGithub : Boolean) : EnvFile [0..1]
|
||||||
|
}
|
||||||
|
class ToolChecker {
|
||||||
|
+check(tools : String [1..*]) : ToolCheck
|
||||||
|
}
|
||||||
|
class Preflight {
|
||||||
|
+check(request : ProjectRequest) : PreflightResult
|
||||||
|
}
|
||||||
|
abstract class GitHost <<facade>> {
|
||||||
|
-name : String
|
||||||
|
-webAddress : String
|
||||||
|
-apiAddress : String
|
||||||
|
+verifyToken() : Boolean
|
||||||
|
+ownerAccepts(owner : Owner) : Boolean
|
||||||
|
+nameFree(name : String) : Boolean
|
||||||
|
}
|
||||||
|
class GiteaClient <<facade>> {
|
||||||
|
+GiteaClient(configuration : Configuration)
|
||||||
|
+hasLicense(key : String) : Boolean
|
||||||
|
+createRepository(request : ProjectRequest, license : String [0..1]) : GiteaRepository
|
||||||
|
+addPushMirror(source : GiteaRepository, target : GitHubRepository) : PushMirror
|
||||||
|
-requestSync(mirror : PushMirror) : void
|
||||||
|
}
|
||||||
|
class GitHubClient <<facade>> {
|
||||||
|
+GitHubClient(configuration : Configuration)
|
||||||
|
+createEmptyRepository(request : ProjectRequest) : GitHubRepository
|
||||||
|
}
|
||||||
|
class LocalProjectBuilder {
|
||||||
|
+build(directory : Path, source : GiteaRepository, target : GitHubRepository [0..1], sshPassed : Boolean) : LocalProject
|
||||||
|
}
|
||||||
|
class FrameworkInstaller {
|
||||||
|
+install(project : LocalProject, enablePlanGate : Boolean) : InstallResult
|
||||||
|
}
|
||||||
|
class SummaryReport {
|
||||||
|
+compose(request : ProjectRequest) : Summary
|
||||||
|
}
|
||||||
|
|
||||||
|
class Run {
|
||||||
|
-isApply : Boolean
|
||||||
|
}
|
||||||
|
class Configuration {
|
||||||
|
-giteaUrl : String
|
||||||
|
-giteaApiUrl : String
|
||||||
|
-githubWebUrl : String
|
||||||
|
-githubApiUrl : String
|
||||||
|
-giteaSshPort : Integer
|
||||||
|
-mirrorInterval : String
|
||||||
|
-frameworkRepo : String
|
||||||
|
-presetDetails : Map [0..1]
|
||||||
|
}
|
||||||
|
class Credential {
|
||||||
|
-kind : String
|
||||||
|
-value : String
|
||||||
|
}
|
||||||
|
class ToolCheck {
|
||||||
|
-hasGit : Boolean
|
||||||
|
-hasCurl : Boolean
|
||||||
|
-hasJq : Boolean
|
||||||
|
}
|
||||||
|
class PromptSet {
|
||||||
|
-prompts : String [1..*]
|
||||||
|
}
|
||||||
|
class ProjectRequest {
|
||||||
|
-name : String
|
||||||
|
-description : String
|
||||||
|
-visibility : Visibility
|
||||||
|
-directory : Path
|
||||||
|
-enablePlanGate : Boolean
|
||||||
|
-license : String [0..1]
|
||||||
|
}
|
||||||
|
class Owner {
|
||||||
|
-name : String
|
||||||
|
-kind : String
|
||||||
|
}
|
||||||
|
class PreflightResult {
|
||||||
|
-tokensWork : Boolean
|
||||||
|
-ownersAccept : Boolean
|
||||||
|
-nameIsFree : Boolean
|
||||||
|
-licenseIsOffered : Boolean
|
||||||
|
-sshPassed : Boolean
|
||||||
|
}
|
||||||
|
abstract class Repository {
|
||||||
|
-name : String
|
||||||
|
-description : String
|
||||||
|
-visibility : Visibility
|
||||||
|
-address : String
|
||||||
|
}
|
||||||
|
class GiteaRepository
|
||||||
|
class GitHubRepository
|
||||||
|
class LicenseFile {
|
||||||
|
-key : String
|
||||||
|
}
|
||||||
|
class PushMirror {
|
||||||
|
-interval : String
|
||||||
|
-syncOnCommit : Boolean
|
||||||
|
}
|
||||||
|
class LocalProject {
|
||||||
|
-directory : Path
|
||||||
|
}
|
||||||
|
class Remote {
|
||||||
|
-name : String
|
||||||
|
-address : String
|
||||||
|
}
|
||||||
|
class Submodule {
|
||||||
|
-name : String
|
||||||
|
-address : String
|
||||||
|
}
|
||||||
|
class HookSetup {
|
||||||
|
-areSkillsInstalled : Boolean
|
||||||
|
-areHooksInstalled : Boolean
|
||||||
|
-isPlanGateEnabled : Boolean
|
||||||
|
}
|
||||||
|
class EnvFile {
|
||||||
|
-address : Path
|
||||||
|
-keys : String [1..3]
|
||||||
|
}
|
||||||
|
class Template {
|
||||||
|
-name : String
|
||||||
|
-isCopied : Boolean
|
||||||
|
}
|
||||||
|
class InstallResult <<dto>>
|
||||||
|
class Summary {
|
||||||
|
-createdItems : String [0..*]
|
||||||
|
-skippedItems : String [0..*]
|
||||||
|
-nextSteps : String [0..*]
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectCreator ..> ConfigLoader : creates
|
||||||
|
ProjectCreator ..> ToolChecker : creates
|
||||||
|
ProjectCreator ..> CredentialCollector : creates
|
||||||
|
ProjectCreator ..> EnvFileWriter : creates [0..1]
|
||||||
|
ProjectCreator ..> Preflight : creates
|
||||||
|
ProjectCreator ..> GiteaClient : creates
|
||||||
|
ProjectCreator ..> GitHubClient : creates [0..1]
|
||||||
|
ProjectCreator ..> LocalProjectBuilder : creates
|
||||||
|
ProjectCreator ..> FrameworkInstaller : creates
|
||||||
|
ProjectCreator ..> SummaryReport : creates
|
||||||
|
Preflight ..> GiteaClient : asks
|
||||||
|
Preflight ..> GitHubClient : asks [0..1]
|
||||||
|
GitHost <|-- GiteaClient
|
||||||
|
GitHost <|-- GitHubClient
|
||||||
|
GitHost "1" --> "0..*" Owner : has
|
||||||
|
GiteaClient ..> Configuration
|
||||||
|
GitHubClient ..> Configuration
|
||||||
|
|
||||||
|
ProjectCreator "0..*" --> "1" Run
|
||||||
|
Run "1" *-- "1" Configuration
|
||||||
|
Run "1" *-- "1" ToolCheck
|
||||||
|
Run "1" *-- "0..1" ProjectRequest
|
||||||
|
Run "1" --> "1" PromptSet : returns
|
||||||
|
Configuration "1" *-- "1..3" Credential
|
||||||
|
|
||||||
|
ProjectRequest "0..*" --> "1" Owner : giteaOwner
|
||||||
|
ProjectRequest "0..*" --> "0..1" Owner : githubOwner
|
||||||
|
ProjectRequest "1" *-- "0..1" PreflightResult
|
||||||
|
ProjectRequest "1" --> "0..1" GiteaRepository : stored in
|
||||||
|
ProjectRequest "1" --> "0..1" GitHubRepository : also stored in
|
||||||
|
ProjectRequest "1" --> "0..1" LocalProject : working copy
|
||||||
|
Summary "0..*" --> "1" ProjectRequest : reports on
|
||||||
|
|
||||||
|
Repository <|-- GiteaRepository
|
||||||
|
Repository <|-- GitHubRepository
|
||||||
|
Repository "0..*" --> "1" Owner : owned by
|
||||||
|
GiteaRepository "1" *-- "0..1" LicenseFile
|
||||||
|
PushMirror "0..*" --> "1" GiteaRepository : source
|
||||||
|
PushMirror "0..*" --> "1" GitHubRepository : target
|
||||||
|
PushMirror "0..*" --> "1" Credential : authorised by
|
||||||
|
|
||||||
|
LocalProject "1" *-- "1..2" Remote
|
||||||
|
Remote "0..*" --> "1" Repository : points to
|
||||||
|
LocalProject "1" *-- "1" Submodule
|
||||||
|
LocalProject "1" *-- "1" HookSetup
|
||||||
|
LocalProject "1" *-- "0..*" Template
|
||||||
|
LocalProject "1" *-- "0..1" EnvFile
|
||||||
|
EnvFile "0..*" --> "1..3" Credential : copy of
|
||||||
|
InstallResult "0..*" --> "1" Submodule
|
||||||
|
InstallResult "0..*" --> "1" HookSetup
|
||||||
|
InstallResult "0..*" --> "0..*" Template
|
||||||
|
|
||||||
|
ProjectRequest "0..*" --> "1" Visibility
|
||||||
|
Repository "0..*" --> "1" Visibility
|
||||||
|
@enduml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Class Table
|
||||||
|
|
||||||
|
| Class | Refines (Domain Model concept) | Responsibility | Attributes | Operations |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `ProjectCreator` | none (controller for the system operations of [OC-001]) | Receives the two system operations, sequences the steps and stops on the first failure. | none | `startProjectCreation`, `provideProjectDetails` |
|
||||||
|
| `ConfigLoader` | Configuration | Reads `config.env` and `.env` as plain text and validates every value, preset project details included. | none | `load` |
|
||||||
|
| `CredentialCollector` | none (system concept) | Asks, without echo, for a credential that `.env` does not provide and validates it like one read from `.env`. | none | `collect` |
|
||||||
|
| `EnvFileWriter` | Credentials File | Creates the project's own `.env` with the credentials the project needs: owner-only, excluded from git, never replaced without a yes. | none | `write` |
|
||||||
|
| `ToolChecker` | none (system concept `ToolCheck`) | Detects the required and optional tools. | none | `check` |
|
||||||
|
| `Preflight` | none (system concept `PreflightResult`) | Runs the read-only checks of both hosts before anything is created. | none | `check` |
|
||||||
|
| `GitHost` | Git Host | The operations every host offers: check the token, check that an owner accepts new repositories, check that a name is free. | `name`, `webAddress`, `apiAddress` | `verifyToken`, `ownerAccepts`, `nameFree` |
|
||||||
|
| `GiteaClient` | Git Host (Gitea) | Hides the Gitea API and its token; creates the repository and the push mirror. | none beyond `GitHost` (uses `Configuration`) | `GiteaClient`, `hasLicense`, `createRepository`, `addPushMirror`, `requestSync` |
|
||||||
|
| `GitHubClient` | Git Host (GitHub) | Hides the GitHub API and its token; creates the empty repository. | none beyond `GitHost` (uses `Configuration`) | `GitHubClient`, `createEmptyRepository` |
|
||||||
|
| `LocalProjectBuilder` | Local Project, Remote | Creates the project directory, its git repository and its credential-free remotes. | none | `build` |
|
||||||
|
| `FrameworkInstaller` | Framework, Framework Setup, Template | Adds the framework submodule, installs skills and hooks once, and copies the templates without overwriting. | none | `install` |
|
||||||
|
| `SummaryReport` | Summary | Composes the report of what was created, skipped or failed. | none | `compose` |
|
||||||
|
| `Run` | none (system concept) | Holds the state of one execution. | `isApply` | none |
|
||||||
|
| `Configuration` | Configuration | Holds the service addresses, the credentials and any preset project details. | `giteaUrl`, `giteaApiUrl`, `githubWebUrl`, `githubApiUrl`, `giteaSshPort`, `mirrorInterval`, `frameworkRepo`, `presetDetails` | none |
|
||||||
|
| `Credential` | Access Token | Holds a secret in memory only; it never becomes part of an address or a message. | `kind`, `value` | none |
|
||||||
|
| `ToolCheck` | none (system concept) | Records which tools are present. | `hasGit`, `hasCurl`, `hasJq` | none |
|
||||||
|
| `PromptSet` | none (system concept) | The questions still to ask; a detail preset in `config.env` is not in it. | `prompts` | none |
|
||||||
|
| `ProjectRequest` | Project | Holds the details of the project being created. | `name`, `description`, `visibility`, `directory`, `enablePlanGate`, `license` | none |
|
||||||
|
| `Owner` | Owner | A user or organization on a host. | `name`, `kind` | none |
|
||||||
|
| `PreflightResult` | none (system concept) | Records the outcome of the preflight checks. | `tokensWork`, `ownersAccept`, `nameIsFree`, `licenseIsOffered`, `sshPassed` | none |
|
||||||
|
| `Repository` | Repository | Common data of a repository on a host. | `name`, `description`, `visibility`, `address` | none |
|
||||||
|
| `GiteaRepository` | Gitea Repository | The source of truth. | none beyond `Repository` | none |
|
||||||
|
| `GitHubRepository` | GitHub Repository | Receives its content from the mirror. | none beyond `Repository` | none |
|
||||||
|
| `LicenseFile` | License | The license file in the Gitea repository when a license applies. | `key` | none |
|
||||||
|
| `PushMirror` | Mirror | The Gitea to GitHub push mirror. | `interval`, `syncOnCommit` | none |
|
||||||
|
| `LocalProject` | Local Project | The project directory on the Maintainer's machine. | `directory` | none |
|
||||||
|
| `Remote` | Remote | A named link to a repository (`origin`, `github`), without a credential. | `name`, `address` | none |
|
||||||
|
| `Submodule` | Framework | The framework added to the local project. | `name`, `address` | none |
|
||||||
|
| `HookSetup` | Framework Setup | Records the skills and hooks installed and the plan gate state. | `areSkillsInstalled`, `areHooksInstalled`, `isPlanGateEnabled` | none |
|
||||||
|
| `EnvFile` | Credentials File | The `.env` of the project: a copy of the credentials it needs. | `address`, `keys` | none |
|
||||||
|
| `Template` | Template | A framework file copied into the project. | `name`, `isCopied` | none |
|
||||||
|
| `InstallResult` | none (carries the result of one operation) | Returns the submodule, the hook setup and the templates of `install`. | none | none |
|
||||||
|
| `Summary` | Summary | The report returned to the Maintainer; it contains no credential. | `createdItems`, `skippedItems`, `nextSteps` | none |
|
||||||
|
| `Visibility` | none (enumeration of a Project and Repository attribute) | The two allowed visibilities. | `private`, `public` | none |
|
||||||
|
|
||||||
|
## Method Traceability
|
||||||
|
|
||||||
|
| Method signature | Operation Contract / SD message |
|
||||||
|
| --- | --- |
|
||||||
|
| `ProjectCreator.startProjectCreation() : PromptSet` | [OC-001] `startProjectCreation`; [SD-001] `startProjectCreation()` |
|
||||||
|
| `ProjectCreator.provideProjectDetails(name, description, visibility, giteaOwner, githubOwner, directory, enablePlanGate, writeEnvFile) : Summary` | [OC-001] `provideProjectDetails`; [SD-001] `provideProjectDetails(...)` |
|
||||||
|
| `ConfigLoader.load(configFile, envFile) : Configuration` | [SD-001] `load(config.env, .env)`; [OC-001] `startProjectCreation` P2 |
|
||||||
|
| `CredentialCollector.collect(configuration, kinds) : Configuration` | [SD-001] `collect(configuration, GITEA_TOKEN)` and `collect(configuration, GITHUB_PAT, GITHUB_USER)`; [OC-001] `startProjectCreation` P2 and the precondition of `provideProjectDetails` |
|
||||||
|
| `EnvFileWriter.write(project, configuration, hasGithub) : EnvFile` | [SD-001] `write(localProject, configuration, githubOwner present)`; [OC-001] `provideProjectDetails` P14 |
|
||||||
|
| `ToolChecker.check(tools) : ToolCheck` | [SD-001] `check(git, curl, jq)`; [OC-001] `startProjectCreation` P3 |
|
||||||
|
| `Preflight.check(request) : PreflightResult` | [SD-001] `check(request)`; [OC-001] `provideProjectDetails` P2 |
|
||||||
|
| `GiteaClient(configuration)` | [SD-001] `new(configuration)` to `GiteaClient` |
|
||||||
|
| `GitHost.verifyToken() : Boolean` | [SD-001] `verifyToken()` from `Preflight` to either client; P2 |
|
||||||
|
| `GitHost.ownerAccepts(owner) : Boolean` | [SD-001] `ownerAccepts(giteaOwner)` and `ownerAccepts(githubOwner)`; P2 |
|
||||||
|
| `GitHost.nameFree(name) : Boolean` | [SD-001] `nameFree(name)` to either client; P2 |
|
||||||
|
| `GiteaClient.hasLicense(key) : Boolean` | [SD-001] `hasLicense(license)`; P2 |
|
||||||
|
| `GiteaClient.createRepository(request, license) : GiteaRepository` | [SD-001] `createRepository(request, license)`; P3, P4 |
|
||||||
|
| `GiteaClient.addPushMirror(source, target) : PushMirror` | [SD-001] `addPushMirror(giteaRepository, gitHubRepository)`; P6 |
|
||||||
|
| `GiteaClient.requestSync(mirror) : void` | [SD-001] `requestSync(pushMirror)`; P6 |
|
||||||
|
| `GitHubClient(configuration)` | [SD-001] `new(configuration)` to `GitHubClient` |
|
||||||
|
| `GitHubClient.createEmptyRepository(request) : GitHubRepository` | [SD-001] `createEmptyRepository(request)`; P5 |
|
||||||
|
| `LocalProjectBuilder.build(directory, source, target, sshPassed) : LocalProject` | [SD-001] `build(directory, giteaRepository, gitHubRepository, sshPassed)`; P7, P8, P9 |
|
||||||
|
| `FrameworkInstaller.install(project, enablePlanGate) : InstallResult` | [SD-001] `install(localProject, enablePlanGate)`; P10, P11, P12 |
|
||||||
|
| `SummaryReport.compose(request) : Summary` | [SD-001] `compose(projectRequest)`; P13 |
|
||||||
|
|
||||||
|
## Pattern Annotations
|
||||||
|
|
||||||
|
| Pattern | Classes | Rationale |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Controller (GRASP) | `ProjectCreator` | One entry for the system operations; coordinates and does no HTTP, git or file work itself |
|
||||||
|
| Facade (GoF) | `GitHost`, `GiteaClient`, `GitHubClient` | Each client hides one host's HTTP API and keeps the token inside; no other class sees a credential. `GitHost` holds the operations both share |
|
||||||
|
| Pure Fabrication (GRASP) | `ConfigLoader`, `ToolChecker`, `CredentialCollector`, `EnvFileWriter`, `Preflight`, `LocalProjectBuilder`, `FrameworkInstaller`, `SummaryReport` | No domain concept owns these responsibilities; small units keep cohesion high |
|
||||||
|
| Creator (GRASP) | `ConfigLoader` creates `Configuration`; `GiteaClient` creates `GiteaRepository` and `PushMirror` | The creating class holds the data needed to build the object |
|
||||||
|
| Protection from variations (GRASP) | `GiteaClient`, `GitHubClient`, `ProjectRequest` | The optional GitHub path is decided by the controller; the clients do not know it |
|
||||||
|
| Data Transfer Object (GoF-style) | `InstallResult` | Carries the three results of `install` in one return value |
|
||||||
|
|
||||||
|
## Dependency Check
|
||||||
|
|
||||||
|
No circular dependency. `ProjectCreator` depends on every helper class and no helper depends on it. `Preflight` depends on the two clients; the clients extend `GitHost` and depend only on `Configuration`. The data classes form a tree: `Run` holds `Configuration`, `ToolCheck` and `ProjectRequest`; `ProjectRequest` reaches the repositories and the `LocalProject`; `Summary` points at `ProjectRequest` and nothing points back at it. `CredentialCollector` and `EnvFileWriter` depend only on `Configuration`, `Credential` and `LocalProject`; the only class that holds a secret after the run is `EnvFile`, and only as a copy written to the Maintainer's own disk. `Repository` is shared by `Remote` and `PushMirror` without a cycle.
|
||||||
|
|
||||||
|
SOLID check: no class has more than one reason to change (one host API, one kind of local work, one report); the clients can be replaced behind the same operations; the controller depends on the operations, not on how a host or git is called. `ProjectCreator` has two operations and no data, so it is not a god class.
|
||||||
|
|
||||||
|
## Implementation Mapping
|
||||||
|
|
||||||
|
| Design class | Where it lives in `src/` |
|
||||||
|
| --- | --- |
|
||||||
|
| `ProjectCreator` | `create-project.sh` (`main`), `lib/apply.sh` |
|
||||||
|
| `ConfigLoader` | `lib/config.sh` (`load_configuration`), `lib/validate.sh` |
|
||||||
|
| `ToolChecker` | `lib/tools.sh` |
|
||||||
|
| `CredentialCollector` | planned for [MIL-005]: `lib/credentials.sh`, with `lib/prompts.sh` |
|
||||||
|
| `EnvFileWriter` | planned for [MIL-005]: `lib/envfile.sh` |
|
||||||
|
| `Preflight` | `lib/preflight.sh` |
|
||||||
|
| `GitHost`, `GiteaClient`, `GitHubClient` | `lib/api.sh`, `lib/http.sh`, `lib/json.sh`, `lib/repositories.sh`, `lib/mirror.sh`, `lib/hosts.sh` |
|
||||||
|
| `LocalProjectBuilder` | `lib/localproject.sh`, `lib/git.sh` |
|
||||||
|
| `FrameworkInstaller` | `lib/framework.sh` |
|
||||||
|
| `SummaryReport` | `lib/steps.sh`, `lib/plan.sh` |
|
||||||
|
| `PromptSet`, `ProjectRequest` | `lib/project.sh`, `lib/prompts.sh` |
|
||||||
|
| `Run`, `Configuration`, `Credential`, `PreflightResult` | the state arrays declared in `lib/constants.sh` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[DCD-001]: ./uc-001/dcd.md
|
||||||
|
[DM-002]: ./domain-model.md
|
||||||
|
[UC-001]: ./uc-001/uc.md
|
||||||
|
[OC-001]: ./uc-001/oc.md
|
||||||
|
[SD-001]: ./uc-001/sd.md
|
||||||
|
[MIL-005]: ./milestones/mil-005-credentials.md
|
||||||
|
[DICT-001]: ./dictionary.md
|
||||||
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
|
[d773fa9]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/d773fa91df5a54090254e12e074880fb6526a9ff
|
||||||
+27
-19
@@ -9,7 +9,8 @@
|
|||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Initial version, terms of UC-001 | [02875ae] |
|
| 2026-10-06 | Accepted | Jens Tirsvad Nielsen | S02 | Added Credentials File / EnvFile | [ded26a6] |
|
||||||
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | LicenseFile definition no longer tied to GitHub | [d773fa9] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,23 +22,24 @@ Maps each Product Owner (PO) term to its professional IT term. PO language: Engl
|
|||||||
|
|
||||||
| PO term | Language | IT term | Definition | Used as PO term in | Used as IT term in |
|
| 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 |
|
| Maintainer | en | Maintainer | The person who creates a new project. | DM, UC, US | OC, SD, DCD |
|
||||||
| Project | en | ProjectRequest | The new software project being set up, with its name, description and visibility. | 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, DCD |
|
||||||
| Configuration | en | Configuration | The service addresses and access tokens set up before starting. | DM, UC | OC, SD |
|
| Configuration | en | Configuration | The service addresses and access tokens set up before starting. | DM, UC | OC, SD, DCD |
|
||||||
| Git Host | en | GitHost | A service that holds repositories: Gitea or GitHub. | DM, UC | OC, SD |
|
| Git Host | en | GitHost | A service that holds repositories: Gitea or GitHub. | DM, UC | OC, SD, DCD |
|
||||||
| Access Token | en | Credential | A secret that lets the Maintainer act on a Git Host; never part of an address. | 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, DCD |
|
||||||
| Owner | en | Owner | The user or organization on a Git Host that owns repositories. | DM, UC | OC, SD |
|
| Owner | en | Owner | The user or organization on a Git Host that owns repositories. | DM, UC | OC, SD, DCD |
|
||||||
| Repository | en | Repository | A place on a Git Host that holds a project's history. | DM, UC | OC, SD |
|
| Repository | en | Repository | A place on a Git Host that holds a project's history. | DM, UC | OC, SD, DCD |
|
||||||
| Gitea Repository | en | GiteaRepository | The repository on Gitea; the source of truth. | DM, UC | OC, SD |
|
| Gitea Repository | en | GiteaRepository | The repository on Gitea; the source of truth. | DM, UC | OC, SD, DCD |
|
||||||
| GitHub Repository | en | GitHubRepository | The repository on GitHub; it receives its content from the mirror. | DM, UC | OC, SD |
|
| GitHub Repository | en | GitHubRepository | The repository on GitHub; it receives its content from the mirror. | DM, UC | OC, SD, DCD |
|
||||||
| License | en | LicenseFile | The legal terms file (AGPL-3.0) added to the Gitea repository when GitHub is chosen. | DM, UC | OC, SD |
|
| License | en | LicenseFile | The legal terms file added to the Gitea repository when a license applies. | DM, UC | OC, SD, DCD |
|
||||||
| Mirror | en | PushMirror | The push mirror that copies a Gitea repository to a GitHub repository. | DM, UC | OC, SD |
|
| Mirror | en | PushMirror | The push mirror that copies a Gitea repository to a GitHub repository. | DM, UC | OC, SD, DCD |
|
||||||
| Local Project | en | LocalProject | The project directory on the Maintainer's machine. | DM, UC | OC, SD |
|
| Local Project | en | LocalProject | The project directory on the Maintainer's machine. | DM, UC | OC, SD, DCD |
|
||||||
| Remote | en | Remote | A named link from a local project to a repository. | DM, UC | OC, SD |
|
| Remote | en | Remote | A named link from a local project to a repository. | DM, UC | OC, SD, DCD |
|
||||||
| 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 | en | Submodule | The SQA-QC-Framework added to a local project; the IT term names how it is attached. | DM, UC | OC, SD, DCD |
|
||||||
| Framework Setup | en | HookSetup | The skills and git hooks installed from the framework, with the plan gate on or off. | 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, DCD |
|
||||||
| Template | en | Template | A framework file copied into a project. | DM, UC | OC, SD |
|
| Template | en | Template | A framework file copied into a project. | DM, UC | OC, SD, DCD |
|
||||||
| Summary | en | Summary | The report of what was created, skipped or failed and how to continue. | DM, UC | OC, SD |
|
| Credentials File | en | EnvFile | The file in the local project that holds a copy of the credentials the project needs; owner-only and ignored by git. | DM, UC | OC, SD, DCD |
|
||||||
|
| Summary | en | Summary | The report of what was created, skipped or failed and how to continue. | DM, UC | OC, SD, DCD |
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
@@ -47,6 +49,10 @@ Maps each Product Owner (PO) term to its professional IT term. PO language: Engl
|
|||||||
- `Run`, `ToolCheck`, `PreflightResult` and `PromptSet` appear in [OC-001] but
|
- `Run`, `ToolCheck`, `PreflightResult` and `PromptSet` appear in [OC-001] but
|
||||||
have no PO term: they are system concepts, not domain concepts, and are not
|
have no PO term: they are system concepts, not domain concepts, and are not
|
||||||
in the Domain Model.
|
in the Domain Model.
|
||||||
|
- `InstallResult` and the enumeration `Visibility` appear only in [DCD-001]:
|
||||||
|
`InstallResult` carries the three results of one operation, and `Visibility`
|
||||||
|
is the type of the Project and Repository attribute of the same name. Neither
|
||||||
|
is a domain concept.
|
||||||
- A new concept in a Domain Model gets a row here in the same change.
|
- A new concept in a Domain Model gets a row here in the same change.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -56,4 +62,6 @@ Maps each Product Owner (PO) term to its professional IT term. PO language: Engl
|
|||||||
[DM-001]: ./uc-001/dm.md
|
[DM-001]: ./uc-001/dm.md
|
||||||
[DM-002]: ./domain-model.md
|
[DM-002]: ./domain-model.md
|
||||||
[OC-001]: ./uc-001/oc.md
|
[OC-001]: ./uc-001/oc.md
|
||||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
[DCD-001]: ./uc-001/dcd.md
|
||||||
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
|
[d773fa9]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/d773fa91df5a54090254e12e074880fb6526a9ff
|
||||||
|
|||||||
+18
-6
@@ -9,7 +9,8 @@
|
|||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Initial version, created from [DM-001] (UC-001) | [02875ae] |
|
| 2026-10-06 | Accepted | Jens Tirsvad Nielsen | S02 | Added Credentials File (from DM-001, UC-001 step 9) | [ded26a6] |
|
||||||
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | License applies when configured, not only when GitHub is chosen (from DM-001) | [d773fa9] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,7 +32,9 @@ class Project {
|
|||||||
description
|
description
|
||||||
visibility
|
visibility
|
||||||
}
|
}
|
||||||
class Configuration
|
class Configuration {
|
||||||
|
preset project details
|
||||||
|
}
|
||||||
class "Git Host" as GitHost {
|
class "Git Host" as GitHost {
|
||||||
name
|
name
|
||||||
web address
|
web address
|
||||||
@@ -76,6 +79,9 @@ class "Framework Setup" as FrameworkSetup {
|
|||||||
class Template {
|
class Template {
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
|
class "Credentials File" as CredentialsFile {
|
||||||
|
address
|
||||||
|
}
|
||||||
class Summary {
|
class Summary {
|
||||||
created items
|
created items
|
||||||
skipped items
|
skipped items
|
||||||
@@ -105,6 +111,8 @@ LocalProject "1" --> "1" FrameworkSetup : has
|
|||||||
FrameworkSetup "0..*" --> "1" Framework : is installed from
|
FrameworkSetup "0..*" --> "1" Framework : is installed from
|
||||||
Framework "1" --> "1..*" Template : provides
|
Framework "1" --> "1..*" Template : provides
|
||||||
LocalProject "1" --> "0..*" Template : contains a copy of
|
LocalProject "1" --> "0..*" Template : contains a copy of
|
||||||
|
LocalProject "1" --> "0..1" CredentialsFile : has
|
||||||
|
CredentialsFile "1" --> "1..2" AccessToken : holds a copy of
|
||||||
Summary "1" --> "1" Project : reports on
|
Summary "1" --> "1" Project : reports on
|
||||||
@enduml
|
@enduml
|
||||||
```
|
```
|
||||||
@@ -115,20 +123,21 @@ Summary "1" --> "1" Project : reports on
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Maintainer | The person who creates a new project (S01 or S02) | name | [UC-001] primary actor |
|
| 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 |
|
| 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" |
|
| Configuration | The service addresses and access tokens the Maintainer has set up before starting, and any project details preset in it | preset project details (optional) | [UC-001] precondition, steps 2 and 3 |
|
||||||
| Git Host | A service that holds repositories: Gitea or GitHub | name, web address, API address | [UC-001] steps 5 to 7 "GitHub", "Gitea" |
|
| 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" |
|
| 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" |
|
| 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" |
|
| 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 |
|
| 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 |
|
| 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" |
|
| License | The legal terms file added to a Gitea Repository when a license applies: the one set in the Configuration, or AGPL-3.0 when GitHub is chosen and none is set | name | [UC-001] step 6 "license" |
|
||||||
| Mirror | The push mirror that copies a Gitea Repository to a GitHub Repository | interval, sync on commit | [UC-001] step 7 "push mirror" |
|
| 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" |
|
| 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" |
|
| 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 | 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" |
|
| 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" |
|
| Template | A file the Framework provides to copy into a project (`AGENTS.md`, artifact registry) | name | [UC-001] step 9 "templates" |
|
||||||
|
| Credentials File | The file in a Local Project that holds a copy of the Access Tokens (and the GitHub account name) the project needs; readable by its owner only and ignored by git | address | [UC-001] step 9 "credentials file" |
|
||||||
| 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" |
|
| 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
|
## Association Table
|
||||||
@@ -143,7 +152,7 @@ Summary "1" --> "1" Project : reports on
|
|||||||
| Owner | owns | Repository | 1 to 0..* |
|
| Owner | owns | Repository | 1 to 0..* |
|
||||||
| Project | is stored in | Gitea Repository | 1 to 1 |
|
| Project | is stored in | Gitea Repository | 1 to 1 |
|
||||||
| Project | is also stored in | GitHub Repository | 1 to 0..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) |
|
| Gitea Repository | has | License | 1 to 0..1 (1 when a license applies) |
|
||||||
| Mirror | copies from | Gitea Repository | 1 to 1 |
|
| Mirror | copies from | Gitea Repository | 1 to 1 |
|
||||||
| Mirror | copies to | GitHub Repository | 1 to 1 |
|
| Mirror | copies to | GitHub Repository | 1 to 1 |
|
||||||
| Mirror | is authorised by | Access Token | 0..* to 1 |
|
| Mirror | is authorised by | Access Token | 0..* to 1 |
|
||||||
@@ -155,6 +164,8 @@ Summary "1" --> "1" Project : reports on
|
|||||||
| Framework Setup | is installed from | Framework | 0..* to 1 |
|
| Framework Setup | is installed from | Framework | 0..* to 1 |
|
||||||
| Framework | provides | Template | 1 to 1..* |
|
| Framework | provides | Template | 1 to 1..* |
|
||||||
| Local Project | contains a copy of | Template | 1 to 0..* |
|
| Local Project | contains a copy of | Template | 1 to 0..* |
|
||||||
|
| Local Project | has | Credentials File | 1 to 0..1 |
|
||||||
|
| Credentials File | holds a copy of | Access Token | 1 to 1..2 |
|
||||||
| Summary | reports on | Project | 1 to 1 |
|
| Summary | reports on | Project | 1 to 1 |
|
||||||
|
|
||||||
## Generalizations
|
## Generalizations
|
||||||
@@ -169,4 +180,5 @@ Summary "1" --> "1" Project : reports on
|
|||||||
[SSD-001]: ./uc-001/ssd.md
|
[SSD-001]: ./uc-001/ssd.md
|
||||||
[DICT-001]: ./dictionary.md
|
[DICT-001]: ./dictionary.md
|
||||||
[DM-001]: ./uc-001/dm.md
|
[DM-001]: ./uc-001/dm.md
|
||||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
|
[d773fa9]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/d773fa91df5a54090254e12e074880fb6526a9ff
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 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] |
|
| 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] |
|
||||||
|
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Criterion 2 corrected after the live run: Gitea also adds a README.md with the license | [613a288] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ Decide whether the script creates the Gitea repository and, if GitHub is chosen,
|
|||||||
| # | Criterion (objectively checkable) | Go | No-Go |
|
| # | 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 each host used | Both verified | Any under the wrong owner |
|
| 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 |
|
| 2 | The GitHub repository is created empty. The Gitea repository holds only the AGPL license file and the `README.md` that Gitea generates for it when GitHub is chosen, otherwise it is empty. Neither has a `.gitignore` | Verified | Any other file 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
@@ -76,5 +76,5 @@ Decide whether the script creates the Gitea repository and, if GitHub is chosen,
|
|||||||
[US-001]: ../user-stories.md
|
[US-001]: ../user-stories.md
|
||||||
[UC-001]: ../uc-001/uc.md
|
[UC-001]: ../uc-001/uc.md
|
||||||
[MIL-001]: ./mil-001-foundation.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
|
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||||
|
[613a288]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/613a288dead4c19c00dee6fbb60d46bc3edf8889
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# MIL-004 Configurable Details
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | MIL-004 |
|
||||||
|
| CrossReference | [BC-001], [US-001], [UC-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Initial version | [2a6bb8e] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Decide whether the project details can be preset in `config.env` without weakening the safety questions: a detail that is set there is not asked, an invalid one stops the run, and the confirmations stay interactive.
|
||||||
|
|
||||||
|
## Deliverable
|
||||||
|
|
||||||
|
`create-project.sh` that reads eight optional keys from `config.env`, checks them with the same rules as the prompts, skips the prompt for each key that is set and marks that value as coming from the configuration in the summary. `config.env.example` and the README document the keys. The tests cover every key.
|
||||||
|
|
||||||
|
The keys (a key that is present counts as set, even when empty, but only the description may be empty):
|
||||||
|
|
||||||
|
| Key | Detail | Accepted value |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PROJECT_NAME` | repository name | the repository name rules of the prompt |
|
||||||
|
| `PROJECT_DESCRIPTION` | description | up to 350 characters, no control characters; may be empty |
|
||||||
|
| `PROJECT_VISIBILITY` | visibility | `private` or `public` |
|
||||||
|
| `GITEA_OWNER` | Gitea owner (user or organization) | the Gitea owner rules of the prompt |
|
||||||
|
| `USE_GITHUB` | also create a GitHub repository | `yes` or `no` |
|
||||||
|
| `GITHUB_OWNER` | GitHub owner (user or organization) | the GitHub owner rules; used only when GitHub is used |
|
||||||
|
| `PROJECT_DIRECTORY` | local directory | the directory rules of the prompt |
|
||||||
|
| `ENABLE_PLAN_GATE` | enable the plan gate | `yes` or `no` |
|
||||||
|
|
||||||
|
## Go / No-Go Criteria
|
||||||
|
|
||||||
|
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | For each of the eight keys: when it is present its prompt is not shown and its value is used and shown in the summary as coming from `config.env` | Tests pass | Any prompt shown or value ignored |
|
||||||
|
| 2 | A key that is absent is asked as before; asked and preset details can be mixed | Tests pass | Any asked wrongly |
|
||||||
|
| 3 | A present but empty value is accepted for `PROJECT_DESCRIPTION` and refused, naming the key, for the other seven | Tests pass | Any other result |
|
||||||
|
| 4 | An invalid value stops the run before any request to a host, names the key, and never falls back to asking | Tests pass | A request made or a prompt shown |
|
||||||
|
| 5 | `USE_GITHUB=no` skips the GitHub owner and the GitHub steps; a `GITHUB_OWNER` set while GitHub is not used is ignored with a warning | Tests pass | Any GitHub call or owner prompt |
|
||||||
|
| 6 | With all eight keys set, the only questions left are the confirmations: create now, reuse of an existing repository, directory, hooks path or file, each still defaulting to no | Tests pass | A confirmation skipped |
|
||||||
|
| 7 | The new keys are rejected in `.env`, and credentials are still rejected in `config.env` | Tests pass | Any accepted |
|
||||||
|
| 8 | All acceptance criteria of US-001.04 in [US-001] are met | Verified | Any unmet |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Depends on | Reason |
|
||||||
|
| --- | --- |
|
||||||
|
| [MIL-003] | Needs the complete prompt and apply flow to change |
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
| Business Case objective / KPI / user story | Reference |
|
||||||
|
| --- | --- |
|
||||||
|
| User story US-001.04 | [US-001] |
|
||||||
|
| Objective 8 (details preset in `config.env`) | [BC-001] |
|
||||||
|
| Success criterion 8 | [BC-001] |
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
| Role | Stakeholder ID (SA) |
|
||||||
|
| --- | --- |
|
||||||
|
| Owner | S01 |
|
||||||
|
| Approving reviewer | S02 |
|
||||||
|
|
||||||
|
## Target Date
|
||||||
|
|
||||||
|
2026-11-20 — proposed; the Business Case sets no deadline.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Read and validate the eight optional config keys | Add `PROJECT_NAME`, `PROJECT_DESCRIPTION`, `PROJECT_VISIBILITY`, `GITEA_OWNER`, `USE_GITHUB`, `GITHUB_OWNER`, `PROJECT_DIRECTORY` and `ENABLE_PLAN_GATE` to the `config.env` parser, checked with the validators the prompts use. A present key counts as set; only the description may be empty. Errors name the key. The new keys are rejected in `.env`; credentials stay rejected in `config.env`. | Yes | [UC-001] |
|
||||||
|
| 2 | Use configured details instead of asking, and show their source | In the step that collects the details, take each configured value instead of asking and mark it `(from config.env)` in the summary. `USE_GITHUB=no` skips the GitHub owner; a `GITHUB_OWNER` set while GitHub is not used is ignored with a warning. Extension 3a of UC-001. | Yes | [UC-001] |
|
||||||
|
| 3 | Keep the confirmations interactive | Create now, reuse of an existing repository, directory, hooks path and template files stay questions that default to no, even when every detail is configured. Add tests that prove no run creates or replaces anything without them, because this is where a configurable run could weaken the rule never to overwrite without a yes. | No | |
|
||||||
|
| 4 | Document the keys | Add commented examples to `config.env.example` and a table of the keys to the README, with an example of a run in which only the confirmations are asked, and a note that a key that is present but empty counts as set. | No | |
|
||||||
|
| 5 | Test every key and case | Each key set, absent, empty and invalid; mixed asked and preset details; the `USE_GITHUB` interplay; the source marking in the summary; no prompt text shown for a preset detail; the existing tests unchanged. | No | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[BC-001]: ../business-case.md
|
||||||
|
[US-001]: ../user-stories.md
|
||||||
|
[UC-001]: ../uc-001/uc.md
|
||||||
|
[MIL-003]: ./mil-003-scaffold-and-release.md
|
||||||
|
[2a6bb8e]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/2a6bb8e8afadfe6ca4a621da30e44a372898ca62
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# MIL-005 Credentials
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | MIL-005 |
|
||||||
|
| CrossReference | [BC-001], [US-001], [UC-001], [DCD-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-06 | Accepted | Jens Tirsvad Nielsen | S02 | Initial version | [ded26a6] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Decide whether the script can ask for a credential that `.env` does not provide and create the new project's own `.env`, without exposing a token: asked without echo, written only after a yes, owner-only, ignored by git, never replacing an existing file, and never shown in any output.
|
||||||
|
|
||||||
|
## Deliverable
|
||||||
|
|
||||||
|
`create-project.sh` that (1) no longer stops when `.env` is missing or lacks a credential but asks for it without echo (`GITEA_TOKEN` always; `GITHUB_PAT` and `GITHUB_USER` once GitHub is chosen), validating each value like one read from `.env`; and (2) after the local project exists, asks whether to create a `.env` in it and, on a yes, writes only the keys the project needs: `GITEA_TOKEN`, and `GITHUB_PAT` and `GITHUB_USER` when GitHub was chosen. The file is readable by its owner only, excluded from git through `.git/info/exclude` (no tracked file changes, nothing is committed) and never replaced without a yes. The README and `.env.example` document it. The tests cover every case.
|
||||||
|
|
||||||
|
This changes a security guarantee of the earlier milestones ("a token is never persisted"): a token may now be written to one file, and only after a yes. [BC-001] objective 6, success criterion 1 and the risk table are amended in the same change.
|
||||||
|
|
||||||
|
## Go / No-Go Criteria
|
||||||
|
|
||||||
|
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | `GITEA_TOKEN` missing from `.env`, or `.env` absent: it is asked, not echoed, validated like a token read from `.env`, and the run continues; the same for `GITHUB_PAT` and `GITHUB_USER` once GitHub is chosen, and never for them when GitHub is not chosen | Tests pass | The run stops with an error, a value is echoed or GitHub credentials are asked without GitHub |
|
||||||
|
| 2 | An invalid asked value is refused and asked again without showing it; when input ends the run stops before any request to a host and names the key | Tests pass | A request made or a value shown |
|
||||||
|
| 3 | A credential provided in `.env` is not asked | Tests pass | Any prompt shown |
|
||||||
|
| 4 | The "create `.env`" question is asked after the local project exists and defaults to no; on no, no file is created and the summary says so | Tests pass | A file written without a yes |
|
||||||
|
| 5 | On yes the new project's `.env` exists, holds exactly the needed keys (`GITEA_TOKEN`; plus `GITHUB_PAT` and `GITHUB_USER` when GitHub was chosen), has an owner-only mode (600) from the moment it is created, and `git status` in the project does not list it | Tests pass | Another key, another mode or the file listed |
|
||||||
|
| 6 | An existing `.env` in the project is never replaced without a yes; on no it is kept unchanged and reported | Tests pass | Any replaced without a yes |
|
||||||
|
| 7 | No token appears in any output, summary, log, remote URL, tracked file or file other than the project's `.env`; searched in every file of the new project and in all output of the tests, including under `bash -x` | Tests pass | Any hit |
|
||||||
|
| 8 | Only the project's `.env` changed on disk by this feature: no tracked file, no `.gitignore`, no global git configuration is written | Tests pass | Any other change |
|
||||||
|
| 9 | All acceptance criteria of US-001.05 in [US-001] are met | Verified | Any unmet |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Depends on | Reason |
|
||||||
|
| --- | --- |
|
||||||
|
| [MIL-004] | Needs the prompt flow and the configuration presets it changed |
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
| Business Case objective / KPI / user story | Reference |
|
||||||
|
| --- | --- |
|
||||||
|
| User story US-001.05 | [US-001] |
|
||||||
|
| Objective 9 (credentials asked, project `.env`) and the amended objective 6 | [BC-001] |
|
||||||
|
| Success criteria 1 and 9 | [BC-001] |
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
| Role | Stakeholder ID (SA) |
|
||||||
|
| --- | --- |
|
||||||
|
| Owner | S01 |
|
||||||
|
| Approving reviewer | S02 |
|
||||||
|
|
||||||
|
## Target Date
|
||||||
|
|
||||||
|
2026-11-27 — proposed; the Business Case sets no deadline.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Ask for a credential that `.env` does not provide | `.env` becomes optional. `GITEA_TOKEN` is asked after the configuration is read; `GITHUB_PAT` and `GITHUB_USER` once GitHub is chosen. Read without echo (`read -s`), validated by the existing token and account validators, registered for redaction before any later message, asked again when invalid, a stop naming the key when input ends. A new `lib/credentials.sh` (class `CredentialCollector` of [DCD-001]). Extension 2b of [UC-001]. | Yes | [UC-001] |
|
||||||
|
| 2 | Create the new project's `.env` | After the local project exists and only after a yes (default no): write the needed keys to `.env` created with `umask 077` and mode 600, never replacing an existing file without a yes, and add `.env` to `.git/info/exclude`. Report it in the summary without showing a value. A new `lib/envfile.sh` (class `EnvFileWriter`). Step 9 and extensions 9c and 9d of [UC-001]. | Yes | [UC-001] |
|
||||||
|
| 3 | Keep every token out of everything else | The `bash -x` guard, the redaction and the temporary files keep working with credentials that are asked; the asked value never reaches a command line, output, summary or a file other than the project's `.env`. Add the new function groups to the library layout. | No | |
|
||||||
|
| 4 | Document the feature and its risk | README: credentials may be asked, the project `.env`, what it holds, why it is owner-only and ignored, the risk of a plain-text token on disk, how to say no. `.env.example` and the security decisions section updated. | No | |
|
||||||
|
| 5 | Test every case | Each credential present, missing and invalid; `.env` absent; no GitHub credentials without GitHub; end of input; the question defaults to no; file contents, mode and git exclusion; an existing `.env`; every token searched for in the output and the project; the existing tests unchanged. | No | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[BC-001]: ../business-case.md
|
||||||
|
[US-001]: ../user-stories.md
|
||||||
|
[UC-001]: ../uc-001/uc.md
|
||||||
|
[DCD-001]: ../uc-001/dcd.md
|
||||||
|
[MIL-004]: ./mil-004-configurable-details.md
|
||||||
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# MIL-006 Project License
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | MIL-006 |
|
||||||
|
| CrossReference | [BC-001], [US-001], [UC-001], [DCD-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [d773fa9] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Decide whether the project's license can be set in `config.env` without weakening the existing behaviour: a license that is set applies with or without GitHub, `none` means no license, an absent key keeps today's rule (AGPL-3.0 only when GitHub is chosen), and a license the Gitea server does not offer stops the run before anything is created.
|
||||||
|
|
||||||
|
## Deliverable
|
||||||
|
|
||||||
|
`create-project.sh` that reads one more optional key from `config.env`, `PROJECT_LICENSE`, checks it, resolves the license that applies, checks that Gitea offers it, creates the Gitea repository with it and shows it in the plan and summary. `config.env.example` and the README document the key. The tests cover every case.
|
||||||
|
|
||||||
|
The key (present counts as set, as for the other project details of [MIL-004]; an empty value is refused):
|
||||||
|
|
||||||
|
| Key | Detail | Accepted value |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PROJECT_LICENSE` | the license of the project | a Gitea license key (letters, digits, `.`, `+`, `-`, at most 64 characters), such as `AGPL-3.0` or `MIT`, or `none` |
|
||||||
|
|
||||||
|
The license that applies is resolved in this order: `PROJECT_LICENSE` when set (`none` means no license), otherwise AGPL-3.0 when GitHub is chosen, otherwise none. It is never asked: the key is an optional project detail in `config.env`, not a prompt.
|
||||||
|
|
||||||
|
## Go / No-Go Criteria
|
||||||
|
|
||||||
|
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | With `PROJECT_LICENSE` set to a license the server offers, the Gitea repository is created with that license, with GitHub and without it, and the plan and summary show it as coming from `config.env` | Tests pass | Another license, none, or no marker |
|
||||||
|
| 2 | `PROJECT_LICENSE=none`: the Gitea repository has no license, also when GitHub is chosen | Tests pass | Any license applied |
|
||||||
|
| 3 | `PROJECT_LICENSE` absent: AGPL-3.0 when GitHub is chosen and none otherwise, exactly as before; the existing tests pass unchanged | Tests pass | Any change of behaviour |
|
||||||
|
| 4 | An empty or invalid value stops the run before any request to a host, names the key and never falls back to asking | Tests pass | A request made or a prompt shown |
|
||||||
|
| 5 | A license the Gitea server does not offer stops the run before anything is created and names the license | Tests pass | Anything created |
|
||||||
|
| 6 | The license is never asked, with the key set, absent or invalid | Tests pass | Any prompt for it |
|
||||||
|
| 7 | The mirror, the local history and the other steps are unchanged: with GitHub chosen the license file reaches the local project through the Gitea history, as before | Tests pass | Any other step changed |
|
||||||
|
| 8 | All acceptance criteria of US-001.06 in [US-001] are met | Verified | Any unmet |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Depends on | Reason |
|
||||||
|
| --- | --- |
|
||||||
|
| [MIL-004] | The key is one more project detail read, validated and marked by the code of the configurable details |
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
| Business Case objective / KPI / user story | Reference |
|
||||||
|
| --- | --- |
|
||||||
|
| User story US-001.06 | [US-001] |
|
||||||
|
| Objective 10 (project license in `config.env`) and the amended objective 2 | [BC-001] |
|
||||||
|
| Success criterion 10 | [BC-001] |
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
| Role | Stakeholder ID (SA) |
|
||||||
|
| --- | --- |
|
||||||
|
| Owner | S01 |
|
||||||
|
| Approving reviewer | S02 |
|
||||||
|
|
||||||
|
## Target Date
|
||||||
|
|
||||||
|
2026-12-04 — proposed; the Business Case sets no deadline.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Read and validate `PROJECT_LICENSE` | Add the key to the `config.env` parser and its validator (a license key or `none`; empty refused; errors name the key). Resolve the license that applies into the project request (key set, else AGPL-3.0 with GitHub, else none) and mark it `(from config.env)` in the summary. Never asked. Extensions of step 3 of [UC-001]. | Yes | [UC-001] |
|
||||||
|
| 2 | Apply the license on Gitea, independent of GitHub | Generalize the preflight check from the fixed AGPL-3.0 to the license that applies (checked only when one applies); create the repository with it; the plan, the summary and the reuse warning name the license that applies instead of AGPL-3.0; GitHub receives the file through the mirror as before. Extension 4c and step 6 of [UC-001]. | Yes | [UC-001] |
|
||||||
|
| 3 | Document the key | Commented example in `config.env.example`, a row in the README table of project details, and the rule for the license that applies, including `none` and the default. | No | |
|
||||||
|
| 4 | Test every case | Key set (with and without GitHub), `none`, absent, empty, invalid and not offered by the server; never asked; the summary marker; the existing tests unchanged. | No | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[BC-001]: ../business-case.md
|
||||||
|
[US-001]: ../user-stories.md
|
||||||
|
[UC-001]: ../uc-001/uc.md
|
||||||
|
[DCD-001]: ../uc-001/dcd.md
|
||||||
|
[MIL-004]: ./mil-004-configurable-details.md
|
||||||
|
[d773fa9]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/d773fa91df5a54090254e12e074880fb6526a9ff
|
||||||
+23
-8
@@ -4,23 +4,23 @@
|
|||||||
| Key | Value |
|
| Key | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| ID | PP-001 |
|
| ID | PP-001 |
|
||||||
| CrossReference | [BC-001], [SA-001], [MIL-001], [MIL-002], [MIL-003], [US-001] |
|
| CrossReference | [BC-001], [SA-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [US-001] |
|
||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-05 | Deprecated | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
| 2026-10-06 | Accepted | Jens Tirsvad Nielsen | S02 | Added phase MIL-005 (proposed dates 2026-11-23 to 2026-11-27) | [ded26a6] |
|
||||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Stories per phase: US-001.01 to US-001.03<br>Dates accepted | [02875ae] |
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | Added phase MIL-006 (proposed dates 2026-11-30 to 2026-12-04) | [d773fa9] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Schedule the three phases that deliver RepoFoundry (`create-project.sh` and its documentation) in dependency order. The Business Case sets no deadline, so the dates below are proposals for S01 to confirm.
|
Schedule the six phases that deliver RepoFoundry (`create-project.sh` and its documentation) in dependency order. The Business Case sets no deadline, so the dates below are proposals for S01 to confirm.
|
||||||
|
|
||||||
## Planning Assumptions
|
## Planning Assumptions
|
||||||
|
|
||||||
- Week 1 starts 2026-10-05; the plan ends by 2026-11-13.
|
- Week 1 starts 2026-10-05; the plan ends by 2026-12-04 (the last phase is proposed).
|
||||||
- Phase length: two weeks.
|
- Phase length: two weeks.
|
||||||
- S01 and S02 review each phase through a pull request, as described in [SA-001]. For now one person holds both roles.
|
- S01 and S02 review each phase through a pull request, as described in [SA-001]. For now one person holds both roles.
|
||||||
- The PO language is English, so no translated copies are kept.
|
- The PO language is English, so no translated copies are kept.
|
||||||
@@ -32,6 +32,9 @@ Schedule the three phases that deliver RepoFoundry (`create-project.sh` and its
|
|||||||
| 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] |
|
| 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] |
|
| 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] |
|
| 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] |
|
||||||
|
| Configurable Details | [MIL-004] | 2026-11-16 to 2026-11-20 | 2026-11-20 | S01 | US-001.04 | Project details preset in config.env | |
|
||||||
|
| Credentials | [MIL-005] | 2026-11-23 to 2026-11-27 | 2026-11-27 | S01 | US-001.05 | Missing credentials asked; project .env | |
|
||||||
|
| Project License | [MIL-006] | 2026-11-30 to 2026-12-04 | 2026-12-04 | S01 | US-001.06 | PROJECT_LICENSE in config.env | |
|
||||||
|
|
||||||
```plantuml
|
```plantuml
|
||||||
@startgantt
|
@startgantt
|
||||||
@@ -42,6 +45,12 @@ Project starts 2026-10-05
|
|||||||
[Repositories and Mirror Go/No-Go] happens 2026-10-30
|
[Repositories and Mirror Go/No-Go] happens 2026-10-30
|
||||||
[Scaffold and Release] starts 2026-11-02 and ends 2026-11-13
|
[Scaffold and Release] starts 2026-11-02 and ends 2026-11-13
|
||||||
[Scaffold and Release Go/No-Go] happens 2026-11-13
|
[Scaffold and Release Go/No-Go] happens 2026-11-13
|
||||||
|
[Configurable Details] starts 2026-11-16 and ends 2026-11-20
|
||||||
|
[Configurable Details Go/No-Go] happens 2026-11-20
|
||||||
|
[Credentials] starts 2026-11-23 and ends 2026-11-27
|
||||||
|
[Credentials Go/No-Go] happens 2026-11-27
|
||||||
|
[Project License] starts 2026-11-30 and ends 2026-12-04
|
||||||
|
[Project License Go/No-Go] happens 2026-12-04
|
||||||
@endgantt
|
@endgantt
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -55,11 +64,14 @@ Project starts 2026-10-05
|
|||||||
| Partial-failure reporting | [MIL-002] |
|
| Partial-failure reporting | [MIL-002] |
|
||||||
| Local directory, remotes, framework submodule, skills, hooks, templates, plan gate | [MIL-003] |
|
| Local directory, remotes, framework submodule, skills, hooks, templates, plan gate | [MIL-003] |
|
||||||
| README and SSH prerequisite documentation | [MIL-003] |
|
| README and SSH prerequisite documentation | [MIL-003] |
|
||||||
|
| Project details set in `config.env` instead of asked | [MIL-004] |
|
||||||
|
| Missing credentials asked; the new project's `.env` | [MIL-005] |
|
||||||
|
| Project license set in `config.env` | [MIL-006] |
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
```
|
```
|
||||||
MIL-001 → MIL-002 → MIL-003
|
MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006
|
||||||
```
|
```
|
||||||
|
|
||||||
A No-Go moves every later date by the time needed to rework the failed criteria.
|
A No-Go moves every later date by the time needed to rework the failed criteria.
|
||||||
@@ -86,11 +98,14 @@ A No-Go moves every later date by the time needed to rework the failed criteria.
|
|||||||
[MIL-001]: ./milestones/mil-001-foundation.md
|
[MIL-001]: ./milestones/mil-001-foundation.md
|
||||||
[MIL-002]: ./milestones/mil-002-repositories-and-mirror.md
|
[MIL-002]: ./milestones/mil-002-repositories-and-mirror.md
|
||||||
[MIL-003]: ./milestones/mil-003-scaffold-and-release.md
|
[MIL-003]: ./milestones/mil-003-scaffold-and-release.md
|
||||||
|
[MIL-004]: ./milestones/mil-004-configurable-details.md
|
||||||
|
[MIL-005]: ./milestones/mil-005-credentials.md
|
||||||
|
[MIL-006]: ./milestones/mil-006-project-license.md
|
||||||
[US-001]: ./user-stories.md
|
[US-001]: ./user-stories.md
|
||||||
[UC-001]: ./uc-001/uc.md
|
[UC-001]: ./uc-001/uc.md
|
||||||
[SSD-001]: ./uc-001/ssd.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 43]: https://git.tirsystem.com/TirSystem-BashScript/RepoFoundry/milestone/43
|
||||||
[Milestone 44]: https://git.tirsystem.com/TirSystem-BashScript/RepoFoundry/milestone/44
|
[Milestone 44]: https://git.tirsystem.com/TirSystem-BashScript/RepoFoundry/milestone/44
|
||||||
[Milestone 45]: https://git.tirsystem.com/TirSystem-BashScript/RepoFoundry/milestone/45
|
[Milestone 45]: https://git.tirsystem.com/TirSystem-BashScript/RepoFoundry/milestone/45
|
||||||
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
|
[d773fa9]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/d773fa91df5a54090254e12e074880fb6526a9ff
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# SQA Review Record: End-to-end test and final security review (MIL-003)
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-017 |
|
||||||
|
| CrossReference | [MIL-003], [MIL-002], [RC-016] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [613a288] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: the whole flow of `src/create-project.sh` (branch `mil-003-scaffold-and-release` plus the fixes below), run against real GitHub and Gitea repositories; this is task 6 (issue #20) of [MIL-003] and the live evidence for [MIL-002].
|
||||||
|
- Checklist used: the Go/No-Go criteria of [MIL-003] and the credential, ownership, mirror, submodule and API items named in its task 6. No QC checklist covers an end-to-end run; the code itself was reviewed in [RC-016].
|
||||||
|
- Review date: 2026-10-05
|
||||||
|
- Hosts: Gitea 1.27.3 at `git.tirsystem.com` (SSH on port 10022) and GitHub; real tokens from `.env` (a classic GitHub token with `repo` and `admin:org`; a Gitea token with `write:repository`, `write:organization`, `read:user` and other scopes, but not `write:user`).
|
||||||
|
|
||||||
|
## End-to-end runs
|
||||||
|
|
||||||
|
| Run | Owners | Result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Dry run | `Tirsvad` on both hosts | All preflight checks passed; nothing created. |
|
||||||
|
| First `--apply` | `Tirsvad` on both hosts | Stopped before creating anything: **a defect** (see finding F1). |
|
||||||
|
| A, after the fix | user `Tirsvad` on both hosts | GitHub repository created. Gitea refused: `required=[write:user]`, which the token lacks. The script stopped, reported what existed (GitHub created, Gitea FAILED, the rest not attempted) and how to continue, and deleted nothing. |
|
||||||
|
| B | organization `TirSystem-BashScript` on both hosts, plan gate on | Everything created: both repositories, the mirror, the local project, the framework, skills, hooks, plan gate and templates. No warning. |
|
||||||
|
|
||||||
|
After run B the following was checked independently of the script's own report:
|
||||||
|
|
||||||
|
- **Gitea:** private, owner the organization, default branch `main`, contents `LICENSE` and `README.md`; push mirror to `https://github.com/TirSystem-BashScript/repofoundry-e2e-org.git`, interval `10m0s`, `sync_on_commit` true, `last_error` empty.
|
||||||
|
- **GitHub:** private, owner the organization, contents `LICENSE` and `README.md`, one commit `Initial commit` (arrived through the mirror).
|
||||||
|
- **Local project:** on `main` with that one commit as its whole history, tracking `origin`; `origin` is `ssh://git@git.tirsystem.com:10022/TirSystem-BashScript/repofoundry-e2e-org.git` and `github` is `https://github.com/TirSystem-BashScript/repofoundry-e2e-org.git`; the submodule `framework` comes from `ssh://git@git.tirsystem.com:10022/TirSystem/SQA-QC-Framework.git`; `core.hooksPath` is `framework/githooks` and `planGate.enabled` is true; skills are installed for both harnesses; `AGENTS.md` equals the framework template; no commit was made by the script.
|
||||||
|
- **Hooks and gate, for real:** a commit on `main` was refused ("refusing to commit directly on 'main'"); a `src/` change without a `Task:` trailer on a branch was refused ("plan-first gate"); a documentation-only commit on a branch was accepted.
|
||||||
|
- **Mirror direction, for real:** that commit was pushed to Gitea over SSH and the branch `work` appeared on GitHub within seconds, without a manual sync.
|
||||||
|
|
||||||
|
## Checklist Results (MIL-003 Go/No-Go)
|
||||||
|
|
||||||
|
| # | Criterion | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 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 | Pass | Run B: configured addresses above, credential-free; history is the Gitea 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 | Pass | Run B: `.gitmodules` holds that address; skills, then hooks, then templates; each once. |
|
||||||
|
| 3 | An existing directory, `AGENTS.md` or `docs/artifact-registry.md` is never overwritten without a yes | Pass | Verified by the automated tests with real git (existing directory, conflicting `LICENSE`, existing templates); not repeated on the real hosts. |
|
||||||
|
| 4 | With the plan gate enabled, a commit touching `src/` or `tests/` without a `Task: MIL-NNN#N` trailer is refused | Pass | Run B, for real. |
|
||||||
|
| 5 | An existing `core.hooksPath` is reported and not replaced without consent | Pass | Verified by the automated tests with real git (local and global setting); not repeated on the real hosts. |
|
||||||
|
| 6 | README covers installation, configuration, usage examples, security decisions, error handling and stakeholders, in clear English | N-A | The sections are written; the review by S02 has not happened yet (action item). |
|
||||||
|
| 7 | End-to-end run on disposable repositories passes and the final review records no open security finding | Fail | No open security finding, and the organization-owner run passes on both hosts. The user-owner run could not be completed on Gitea (token scope). |
|
||||||
|
| 8 | All acceptance criteria of US-001.03 in [US-001] are met | Pass | Run B: remotes without credentials, framework, skills, hooks, plan gate and templates in place; the "asks first" criterion by the automated tests. |
|
||||||
|
|
||||||
|
## Final security review
|
||||||
|
|
||||||
|
| Item | Result | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Credential handling | No finding | Both tokens were searched for in every file of the new project, including the whole `.git` folder, and in all output of all runs: zero hits. Remote addresses and `.gitmodules` carry no credential. Tokens went to `curl` through a private configuration file and, for an HTTPS fetch, to git through `GIT_ASKPASS` and the environment (covered by tests; the live runs used SSH). |
|
||||||
|
| Repository ownership | No finding | Created under the owner chosen at the prompt on both hosts (organization in run B; GitHub user in run A). `GITHUB_USER` was only a default. |
|
||||||
|
| Mirror direction | No finding | Gitea is the source: a branch pushed to Gitea reached GitHub on its own. Nothing was pushed from GitHub; that direction was not tested. |
|
||||||
|
| Submodule setup | No finding | Added over SSH on port 10022 from the configured framework repository; the SSH test and the host key check passed. |
|
||||||
|
| API limitations | Findings F2 to F4 | `sync_on_commit` was applied on Gitea 1.27.3 (the upstream bug did not occur). Token scopes and the README Gitea adds are covered below. |
|
||||||
|
| Residual risks | Accepted, documented | The mirror password (the GitHub token) is stored by the Gitea server. The tokens used here are broad (for example `admin:org` on GitHub); tokens limited to what the script needs would reduce the damage of a leak. |
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
| # | Finding | Severity | Status |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| F1 | The real `ssh` used for the SSH test reads standard input and swallowed the answers meant for later prompts, so a run that was piped or pasted stopped before creating anything. The test stub did not read stdin, so no test could see it. | Defect (no data lost) | Fixed: stdin is closed for `ssh`, `git`, `curl` and the framework scripts; the test stub now reads stdin like the real tool; a regression test fails without the fix. |
|
||||||
|
| F2 | Gitea adds a generated `README.md` next to the `LICENSE`. The script, the README and MIL-002 criterion 2 assumed only the license. A repository this script created was therefore counted as "has content" and could not be reused after a partial failure. | Defect | Fixed: `LICENSE` and `LICENSE` + `README.md` count as content created by the script; any other file still counts as content; tests added; README corrected. MIL-002 criterion 2 corrected (new `Proposed` version row, to be accepted). |
|
||||||
|
| F3 | Creating a repository under one's own Gitea account needs the `write:user` scope, not `write:repository`. | Documentation | Fixed in the README, marked as confirmed by the server. |
|
||||||
|
| F4 | Documentation had marked two scopes "not confirmed". Result: `write:user` is needed for user-owned Gitea repositories (F3); organization-owned repositories worked with `write:organization`, `write:repository` and `read:user`, and the minimum was not narrowed down; the GitHub membership check worked with `repo` and `admin:org`, `read:org` alone was not tested. | Documentation | README updated with what was observed. |
|
||||||
|
|
||||||
|
## Files created and external prerequisites
|
||||||
|
|
||||||
|
The script creates, in the new project: `.git`, `.gitmodules`, `framework/` (submodule), `.agents/skills/` and `.claude/skills/`, `AGENTS.md`, `docs/artifact-registry.md`, and (through the Gitea history) `LICENSE` and `README.md`. It never deletes anything.
|
||||||
|
|
||||||
|
External prerequisites: bash 4.4 or later, `git`, `curl`, `mktemp`; optional `jq` and `ssh`. An SSH key in the Gitea account and a known host key for `git.tirsystem.com` port 10022 (the script refuses unknown host keys). A GitHub token that can create repositories and push (classic `repo`), only when GitHub is chosen. A Gitea token with `write:repository`, `write:organization` and `read:user`, plus `write:user` for a repository under one's own account. Push mirrors must be enabled on the Gitea server.
|
||||||
|
|
||||||
|
## Disposable resources left in place (nothing was deleted)
|
||||||
|
|
||||||
|
- Gitea: `TirSystem-BashScript/repofoundry-e2e-org` (private; branches `main` and `work`).
|
||||||
|
- GitHub: `TirSystem-BashScript/repofoundry-e2e-org` (private; branches `main` and `work`).
|
||||||
|
- GitHub: `Tirsvad/repofoundry-e2e-user` (private, empty; created by run A).
|
||||||
|
- A local temporary directory with the run output and the new project.
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go-with-conditions — No open security finding, the organization-owner flow works end to end on both hosts, and the two defects the live run found are fixed with regression tests. Criterion 6 awaits the README review, and criterion 7 is not complete because the user-owner run could not finish on Gitea without `write:user`. 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 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Create a Gitea token that also has `write:user` and repeat run A (the empty GitHub repository `Tirsvad/repofoundry-e2e-user` is offered for reuse) to complete criterion 7 | S01 | 2026-10-16 |
|
||||||
|
| Review the README against criterion 6 | S02 | 2026-10-12 |
|
||||||
|
| Accept the corrected criterion 2 of [MIL-002] (version row `Proposed`) | S02 | 2026-10-12 |
|
||||||
|
| Delete the disposable repositories listed above in the web interfaces | S01 | 2026-10-12 |
|
||||||
|
| Run `tests/run-tests.sh` on Linux and macOS (carried over from [RC-016]) | S02 | 2026-10-30 |
|
||||||
|
| Consider tokens limited to what the script needs, for the Gitea and GitHub accounts used with it | S02 | 2026-10-30 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[MIL-003]: ../../milestones/mil-003-scaffold-and-release.md
|
||||||
|
[MIL-002]: ../../milestones/mil-002-repositories-and-mirror.md
|
||||||
|
[RC-016]: ./rc-016-create-project-sh.md
|
||||||
|
[US-001]: ../../user-stories.md
|
||||||
|
[613a288]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/613a288dead4c19c00dee6fbb60d46bc3edf8889
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# SQA Review Record: MIL-004 and the planning change it causes
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-018 |
|
||||||
|
| CrossReference | [MIL-004], [QC-MIL-001], [US-001], [UC-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [2a6bb8e] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: [MIL-004], and the changes it causes in [BC-001], [US-001], [UC-001], [SSD-001], [OC-001], [SD-001], [DM-001], [DM-002] and [PP-001].
|
||||||
|
- Checklist used: [QC-MIL-001] for [MIL-004]. The other artifacts were changed, not created; their change is checked below for consistency with the checklists of their types (user story, use case, SSD, operation contract, sequence diagram, domain model) and with the PP reference.
|
||||||
|
- Review date: 2026-10-05
|
||||||
|
|
||||||
|
## Checklist Results ([MIL-004], QC-MIL-001)
|
||||||
|
|
||||||
|
| # | Criterion | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | A concrete deliverable is defined for every gate | Pass | The script reads eight optional keys, skips their prompts and marks the source; the example config and the README document the keys; tests cover each key. |
|
||||||
|
| 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.04. |
|
||||||
|
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Depends on [MIL-003], with the reason. |
|
||||||
|
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Maps to objective 8 and success criterion 8 of [BC-001], and to US-001.04. |
|
||||||
|
| 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-20 matches [PP-001]; the Business Case sets no duration, so nothing conflicts. The date is a proposal and is accepted here. |
|
||||||
|
|
||||||
|
## Change checks on the other artifacts
|
||||||
|
|
||||||
|
| Artifact | Change | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| [BC-001] | Objective 8, scope item, success criterion 8 | Pass | The criterion is measurable (tests with each key set, absent, empty and invalid). |
|
||||||
|
| [US-001] | US-001.04 with four acceptance criteria | Pass | Given/when/then; traces to [UC-001] step 3 and [MIL-004]; size and INVEST exception recorded. |
|
||||||
|
| [UC-001] | Precondition, step 3 note, extensions 3a and 3b, business rule | Pass | Extensions name the failure (invalid key) and the outcome (stop before any request, no fallback to asking). |
|
||||||
|
| [SSD-001] | Parameters of `provideProjectDetails` may come from `config.env` | Pass | The message itself is unchanged, so the diagram is unchanged. |
|
||||||
|
| [OC-001] | Preset details are part of the Configuration | Pass | Postcondition and rule added; no new operation. |
|
||||||
|
| [SD-001] | Note that `ConfigLoader` reads and validates the preset details | Pass | The second sequence is unchanged because the arguments are the same whether asked or preset. |
|
||||||
|
| [DM-001], [DM-002] | `Configuration` may hold preset project details | Pass | Both models changed in the same way and the project model stays consistent with the use-case model, as the project rule requires. Dictionary unchanged: no new term. |
|
||||||
|
| [PP-001] | Phase [MIL-004], dependency chain and timeline | Pass | Dates agree with [MIL-004]; scope table and Gantt updated. |
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go — [MIL-004] passes every mandatory criterion and the changes to the other artifacts are consistent with each other and with the Business Case. 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-004]: ../../milestones/mil-004-configurable-details.md
|
||||||
|
[BC-001]: ../../business-case.md
|
||||||
|
[US-001]: ../../user-stories.md
|
||||||
|
[UC-001]: ../../uc-001/uc.md
|
||||||
|
[SSD-001]: ../../uc-001/ssd.md
|
||||||
|
[OC-001]: ../../uc-001/oc.md
|
||||||
|
[SD-001]: ../../uc-001/sd.md
|
||||||
|
[DM-001]: ../../uc-001/dm.md
|
||||||
|
[DM-002]: ../../domain-model.md
|
||||||
|
[PP-001]: ../../project-plan.md
|
||||||
|
[QC-MIL-001]: ../../../framework/qc/qc-milestones-gateways.md
|
||||||
|
[2a6bb8e]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/2a6bb8e8afadfe6ca4a621da30e44a372898ca62
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# SQA Review Record: Shell code review of the MIL-004 change
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-019 |
|
||||||
|
| CrossReference | [MIL-004], [QC-SH-001], [RC-016] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [ceaa7d1] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: the MIL-004 change to `src/create-project.sh` and `src/lib/` (`config.sh`, `constants.sh`, `project.sh`), `tests/test-presets.sh`, the README and `config.env.example`, on branch `mil-004-configurable-details` (commit `75e8e91` plus the fixes listed below), tasks 1 to 5 (issues #27 to #31) of [MIL-004].
|
||||||
|
- Checklist used: [QC-SH-001]. The rest of the code was reviewed in [RC-016].
|
||||||
|
- 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` | Pass | Unchanged. |
|
||||||
|
| 2 | Every expansion is quoted; lists are arrays; tests use `[[ ]]` and `$(...)` | Pass | `shellcheck` is clean. Values from `config.env` are only ever compared, matched against validators or printed; none reaches `eval`, a command line or a file name before it passed a validator. |
|
||||||
|
| 3 | Names follow the conventions | Pass | `check_preset`, `preset_detail`, `source_note` and the `HINT_*` constants follow the rules. See finding F4 on the name `PROJECT_NAME`. |
|
||||||
|
| 4 | Passes `shellcheck` and `bash -n` with no unexplained `disable` comments | Pass | No new `disable`. |
|
||||||
|
| 5 | Errors go to standard error with an `error:` message and a non-zero exit code | Pass | Every refusal goes through `die`, names the key and the file, never the value. |
|
||||||
|
| 6 | Temporary files use `mktemp` with a `trap` cleanup | Pass | Not touched. |
|
||||||
|
| 7 | No secret is written in the script, echoed, or put on a command line | Pass | The new keys carry no credential; they are rejected in `.env`, and credential keys stay rejected in `config.env` (tests). The description is printed in the summary, as it was when asked. |
|
||||||
|
| 8 | A script that changes state defaults to a dry run | Pass | Unchanged: a preset never skips the dry run or "Create these now". Tests prove that with all eight keys set, an empty or missing answer creates nothing. |
|
||||||
|
| 9 | A header comment states purpose, usage, options, environment variables and exit codes | Pass | Fixed during this review: the header said only "asks for the project details"; it now says that details set in `config.env` are not asked. |
|
||||||
|
| 10 | The script implements a task or design it cites; deviations are recorded | Pass | Tasks 1 to 5 of [MIL-004] and extensions 3a and 3b of [UC-001]. Deviations: values are accepted in any case, and `USE_GITHUB`/`ENABLE_PLAN_GATE` take `yes` or `no` only; both are in the README. |
|
||||||
|
| 11 | Behaviour is tested for success, failure and any disabled or bypass path | Pass | 919 checks in the full suite before the review, 0 failed. New: each key set, absent, empty and invalid; mixed asked and preset; `USE_GITHUB` interplay; the summary marker; no prompt text for a preset; the confirmations. Mutation check: making the preset lookup always fail made the tests fail. |
|
||||||
|
| 12 | Formatted with `shfmt` | Pass | No difference. |
|
||||||
|
| 13 | Safe to re-run | Pass | No state is kept. |
|
||||||
|
| 14 | Bash version and external tools stated | Pass | Unchanged. |
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
| # | Finding | Severity | Status |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| F1 | An unquoted `PROJECT_DESCRIPTION` containing ` #` is silently cut at the comment mark (`Tool # for mirrors` becomes `Tool`), with no message. This is how the parser reads every value, but a description is the one free-text key, so it is where it bites. A value with both kinds of quote cannot be set at all (it can still be typed at the prompt). | Low (surprise, no data loss or security effect) | Fixed: the README and `config.env.example` say to put such a value in double quotes; a test pins both behaviours. The both-quotes case is documented as a limit of the parser. |
|
||||||
|
| F2 | The header of `create-project.sh` and the README sentence "The script asks for, in this order" did not mention that preset details are not asked. | Low (documentation) | Fixed. |
|
||||||
|
| F3 | The summary marks the source after the value, so a fully preset run reads `my-app (from config.env) (private (from config.env))`. Correct but noisy, and `USE_GITHUB=yes` is not marked on the GitHub line (only the owner is). | Low (readability) | Accepted: the marker is asserted by the tests and the wording is not a requirement; revisit if the Maintainer wants a table layout. |
|
||||||
|
| F4 | The constant `PROJECT_NAME` (the name of this tool, `RepoFoundry`) and the config key `PROJECT_NAME` (the name of the new project) share a spelling. They never meet in code (the key lives in `CONFIG`), but a reader can confuse them. | Low (maintainability) | Open: renaming the constant is out of scope for this change; a candidate for a later clean-up. |
|
||||||
|
| F5 | A `config.env` that sets `PROJECT_NAME` and `GITEA_OWNER` makes every run use them. Existing repositories are still detected and need the reuse confirmation, so nothing is overwritten. | Info | Documented in the README (per-project configuration). |
|
||||||
|
|
||||||
|
No finding affects credentials, ownership, the mirror direction or the confirmations.
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go — all mandatory criteria pass after the fixes for F1 and F2 (found and fixed during this review; the test for F1 was added; the full suite was rerun afterwards: 923 checks, 0 failed). F3 and F4 are recorded and not blocking. 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 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Decide whether to rename the constant `PROJECT_NAME` (F4) and whether to restyle the summary markers (F3) | S02 | 2026-10-30 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[MIL-004]: ../../milestones/mil-004-configurable-details.md
|
||||||
|
[UC-001]: ../../uc-001/uc.md
|
||||||
|
[RC-016]: ./rc-016-create-project-sh.md
|
||||||
|
[QC-SH-001]: ../../../framework/qc/qc-programming-shell.md
|
||||||
|
[ceaa7d1]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ceaa7d18d8908b4d1e3fb089f238c99b883d71e0
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# SQA Review Record: MIL-005 and the planning change it causes
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-020 |
|
||||||
|
| CrossReference | [MIL-005], [QC-MIL-001], [US-001], [UC-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [ded26a6] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: [MIL-005], and the changes it causes in [BC-001], [US-001], [UC-001], [SSD-001], [OC-001], [SD-001], [DM-001], [DM-002], [DICT-001] and [PP-001]. The two Design Class Diagrams that change with it are reviewed in [RC-021].
|
||||||
|
- Checklist used: [QC-MIL-001] for [MIL-005]. The other artifacts were changed, not created; their change is checked below for consistency with the checklists of their types and with the PP reference.
|
||||||
|
- Review date: 2026-10-06
|
||||||
|
|
||||||
|
## Checklist Results ([MIL-005], QC-MIL-001)
|
||||||
|
|
||||||
|
| # | Criterion | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | A concrete deliverable is defined for every gate | Pass | A script that asks for a missing credential and creates the new project's `.env`, the documentation of the feature and its risk, and tests for every case. |
|
||||||
|
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Nine criteria, each with an objective Go and No-Go, including all acceptance criteria of US-001.05. |
|
||||||
|
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Depends on [MIL-004], with the reason. |
|
||||||
|
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Maps to objective 9, the amended objective 6 and success criteria 1 and 9 of [BC-001], and to US-001.05. |
|
||||||
|
| 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-27 matches [PP-001]; the Business Case sets no duration, so nothing conflicts. The date is a proposal and is accepted here. |
|
||||||
|
|
||||||
|
## Change checks on the other artifacts
|
||||||
|
|
||||||
|
| Artifact | Change | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| [BC-001] | Objective 9, scope items, success criterion 9, a risk and a constraint; objective 6 and success criterion 1 amended | Pass | The security guarantee is weakened on purpose and said so in the same place: a token may be written only to the new project's `.env`, after a yes. The risk row lists the mitigations. |
|
||||||
|
| [US-001] | US-001.05 with five acceptance criteria | Pass | Given/when/then; traces to [UC-001] and [MIL-005]; the last criterion keeps the "no credential in output" rule. |
|
||||||
|
| [UC-001] | Precondition, step 2 and 9 notes, extensions 2b, 9c, 9d, a postcondition and two business rules | Pass | Extension 2b ends before any change when input ends; 9c and 9d keep "never replaced without a yes". |
|
||||||
|
| [SSD-001] | `writeEnvFile` and the credentials not provided in `.env` become parameters; the lifecycle note names the one thing that persists | Pass | One new parameter and a note; the operations are unchanged. |
|
||||||
|
| [OC-001] | Postcondition P14, a precondition, two exceptions, and P2 reworded | Pass | P14 states the contents, the mode, the git exclusion and "no credential shown". |
|
||||||
|
| [SD-001] | `CredentialCollector` and `EnvFileWriter` and their messages | Pass | Every new postcondition has a message; the coverage table is updated. Three lines damaged by an earlier edit (`actor Maintainer`, the first `provideProjectDetails` message, the final `summary` return) are restored in this change. |
|
||||||
|
| [DM-001], [DM-002] | Concept `Credentials File` and two associations | Pass | Both models changed in the same way. |
|
||||||
|
| [DICT-001] | `Credentials File` ↔ `EnvFile` | Pass | One PO term and one IT term, as the dictionary rules require. |
|
||||||
|
| [PP-001] | Phase [MIL-005], dependency chain, timeline | Pass | Dates agree with [MIL-005]. |
|
||||||
|
|
||||||
|
One point for the implementation: the Go/No-Go criterion 5 says the file has mode 600 "from the moment it is created". That means the script must create it under `umask 077` (or with `install -m 600`) and not write it first and restrict it afterwards. Task 2 already says so.
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go — [MIL-005] passes every mandatory criterion and the changes to the other artifacts are consistent with each other. The weakening of the credential guarantee is deliberate, bounded and recorded in the Business Case. 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-005]: ../../milestones/mil-005-credentials.md
|
||||||
|
[BC-001]: ../../business-case.md
|
||||||
|
[US-001]: ../../user-stories.md
|
||||||
|
[UC-001]: ../../uc-001/uc.md
|
||||||
|
[SSD-001]: ../../uc-001/ssd.md
|
||||||
|
[OC-001]: ../../uc-001/oc.md
|
||||||
|
[SD-001]: ../../uc-001/sd.md
|
||||||
|
[DM-001]: ../../uc-001/dm.md
|
||||||
|
[DM-002]: ../../domain-model.md
|
||||||
|
[DICT-001]: ../../dictionary.md
|
||||||
|
[PP-001]: ../../project-plan.md
|
||||||
|
[RC-021]: ./rc-021-dcd.md
|
||||||
|
[QC-MIL-001]: ../../../framework/qc/qc-milestones-gateways.md
|
||||||
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# SQA Review Record: Design Class Diagrams DCD-001 and DCD-002
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-021 |
|
||||||
|
| CrossReference | [DCD-001], [DCD-002], [QC-DCD-001], [SD-001], [OC-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [ded26a6] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instances reviewed: [DCD-001] (use case UC-001) and [DCD-002] (the consolidated project model). [DCD-002] was created from [DCD-001] and the two have the same classes, relationships and tables.
|
||||||
|
- Checklist used: [QC-DCD-001]
|
||||||
|
- Review date: 2026-10-06
|
||||||
|
- PlantUML: the diagrams were not rendered. `render-diagrams.sh` needs a PlantUML server and sends the diagram text to it; none is configured. The syntax was read by hand (see finding F3).
|
||||||
|
|
||||||
|
## Checklist Results
|
||||||
|
|
||||||
|
| # | Criterion | Level | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | SOLID principles applied; no god classes | Mandatory | Pass | Each class has one reason to change: one host API each (`GiteaClient`, `GitHubClient`), local work (`LocalProjectBuilder`), the framework (`FrameworkInstaller`), prompts for credentials (`CredentialCollector`), the project `.env` (`EnvFileWriter`), the report (`SummaryReport`). `ProjectCreator` has two operations and no data. The clients share `GitHost` instead of repeating its three operations. |
|
||||||
|
| 2 | Visibility markers correct and consistent | Mandatory | Pass | Every attribute and operation has `+` or `-`; the only private operation is `GiteaClient.requestSync`, which no other class calls. Enumeration literals carry no marker, as is usual. |
|
||||||
|
| 3 | Association, aggregation, composition and dependency correctly distinguished | Mandatory | Pass | Composition where the part cannot outlive the whole (`Run`, `Configuration`, `LocalProject` and their parts); plain association for the links between independent objects; dependency for "creates" and "asks"; generalization for `Repository` and `GitHost`. No aggregation is used. |
|
||||||
|
| 4 | Multiplicities and navigability specified on all associations | Mandatory | Pass after fix | Found during this review: most associations gave only the target multiplicity. Fixed: both ends now carry a multiplicity and every association has one arrow. Dependencies carry none, as UML does not give them one. |
|
||||||
|
| 5 | Applied design patterns annotated | Optional | Pass | The Pattern Annotations table names Controller, Facade, Pure Fabrication, Creator, Protection from variations and a data transfer object. |
|
||||||
|
| 6 | Method signatures traceable to Operation Contracts and Sequence Diagrams | Mandatory | Pass | The Method Traceability table has a row for each of the 20 operations, each naming the [SD-001] message and the contract postcondition. [SD-001] was aligned in the same change (`createRepository(request, license)`, `compose(request)` and others). |
|
||||||
|
| 7 | Class names consistent with the Domain Model concepts they refine | Mandatory | Pass | The IT terms of [DICT-001] are used (`ProjectRequest` for Project, `Credential` for Access Token, `EnvFile` for Credentials File, and so on). `GitHost` is a class of its own, as the dictionary has one IT term for the PO term. The system concepts without a PO term (`Run`, `ToolCheck`, `PreflightResult`, `PromptSet`, `InstallResult`) are marked as such in the class table. |
|
||||||
|
| 8 | No circular dependencies | Optional | Pass | Stated and argued in the Dependency Check; `Summary` points at `ProjectRequest` and nothing points back; the helper classes depend on the data classes and the controller, never the other way round. |
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
| # | Finding | Severity | Status |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| F1 | Associations gave only the target multiplicity (criterion 4). | Defect | Fixed in both files. |
|
||||||
|
| F2 | The planned classes `CredentialCollector`, `EnvFileWriter` and `EnvFile` (milestone [MIL-005]) are already in the diagram while the code does not exist yet. | Info | Accepted: the Implementation Mapping marks them "planned for MIL-005", so the diagram is a design for code still to come. |
|
||||||
|
| F3 | The PlantUML text was not rendered by a tool. | Low | Open: render with `render-diagrams.sh` once a server is chosen. Constructs used are standard (`abstract class`, `enum`, stereotypes, multiplicities, `skinparam`, `hide empty members`). |
|
||||||
|
| F4 | `Credential` is also the kind of `GITHUB_USER`, which is an account name, not a secret. The dictionary maps Access Token to `Credential`. | Info | Accepted: `kind` tells them apart; `Configuration` holds one to three of them. |
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go — all mandatory criteria pass after the fix for criterion 4, and the optional ones pass. F3 is open and not blocking. 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 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Render the diagrams of [DCD-001], [DCD-002], [SD-001] and the other PlantUML blocks with `render-diagrams.sh` once the Maintainer chooses a server | S01 | 2026-10-16 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[DCD-001]: ../../uc-001/dcd.md
|
||||||
|
[DCD-002]: ../../dcd.md
|
||||||
|
[SD-001]: ../../uc-001/sd.md
|
||||||
|
[OC-001]: ../../uc-001/oc.md
|
||||||
|
[DICT-001]: ../../dictionary.md
|
||||||
|
[MIL-005]: ../../milestones/mil-005-credentials.md
|
||||||
|
[QC-DCD-001]: ../../../framework/qc/qc-dcd.md
|
||||||
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version, UC-001 artifacts and baseline | [02875ae] |
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | Added MIL-005, RC-020 and RC-021 | [ded26a6] |
|
||||||
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | Added MIL-006 | [d773fa9] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,26 +24,31 @@ updated whenever an artifact instance is created or reviewed.
|
|||||||
|
|
||||||
| Artifact Instance | Type | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) |
|
| 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] |
|
| [BC-001] | BC | - | [SA-001], [PP-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [US-001], [UCD-001] | [RC-010], [RC-018], [RC-020] |
|
||||||
| [SA-001] | SA | [BC-001] | [UCD-001], [UC-001], [DICT-001] | [RC-013] |
|
| [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] |
|
| [PP-001] | PP | [BC-001], [SA-001] | [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006] | [RC-012], [RC-018], [RC-020] |
|
||||||
| [MIL-001] | MIL | [BC-001], [PP-001] | [US-001] | [RC-011], [RC-016] |
|
| [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-002] | MIL | [BC-001], [PP-001] | [US-001] | [RC-014], [RC-017] |
|
||||||
| [MIL-003] | MIL | [BC-001], [PP-001] | [US-001] | [RC-015] |
|
| [MIL-003] | MIL | [BC-001], [PP-001] | [US-001] | [RC-015], [RC-017] |
|
||||||
|
| [MIL-004] | MIL | [BC-001], [PP-001] | [US-001] | [RC-018], [RC-019] |
|
||||||
|
| [MIL-005] | MIL | [BC-001], [PP-001] | [US-001] | [RC-020] |
|
||||||
|
| [MIL-006] | MIL | [BC-001], [PP-001] | [US-001] | - |
|
||||||
| [UCD-001] | UCD | [BC-001], [SA-001] | [US-001], [UC-001] | [RC-009] |
|
| [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] |
|
| [US-001] | US | [BC-001], [UCD-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006] | [UC-001] | [RC-001], [RC-020] |
|
||||||
| [UC-001] | UC | [UCD-001], [US-001], [SA-001] | [SSD-001], [DM-001] | [RC-002] |
|
| [UC-001] | UC | [UCD-001], [US-001], [SA-001] | [SSD-001], [DM-001] | [RC-002], [RC-020] |
|
||||||
| [SSD-001] | SSD | [UC-001] | [OC-001] | [RC-003] |
|
| [SSD-001] | SSD | [UC-001] | [OC-001] | [RC-003], [RC-020] |
|
||||||
| [DM-001] | DM | [UC-001], [SSD-001] | [DM-002], [DICT-001], [OC-001] | [RC-004] |
|
| [DM-001] | DM | [UC-001], [SSD-001] | [DM-002], [DICT-001], [OC-001], [DCD-001] | [RC-004], [RC-020] |
|
||||||
| [DM-002] | DM | [DM-001] | [DICT-001] | [RC-005] |
|
| [DM-002] | DM | [DM-001] | [DICT-001], [DCD-001], [DCD-002] | [RC-005], [RC-020] |
|
||||||
| [DICT-001] | DICT | [BC-001], [SA-001], [DM-001], [DM-002] | [OC-001], [SD-001] | [RC-008] |
|
| [DICT-001] | DICT | [BC-001], [SA-001], [DM-001], [DM-002] | [OC-001], [SD-001] | [RC-008], [RC-020] |
|
||||||
| [OC-001] | OC | [SSD-001], [DM-001] | [SD-001] | [RC-006] |
|
| [OC-001] | OC | [SSD-001], [DM-001] | [SD-001] | [RC-006], [RC-020] |
|
||||||
| [SD-001] | SD | [OC-001] | - | [RC-007] |
|
| [SD-001] | SD | [OC-001] | [DCD-001] | [RC-007], [RC-020], [RC-021] |
|
||||||
|
| [DCD-001] | DCD | [UC-001], [DM-001], [DM-002], [OC-001], [SD-001], [DICT-001] | [DCD-002] | [RC-021] |
|
||||||
|
| [DCD-002] | DCD | [DCD-001], [DM-002], [DICT-001] | - | [RC-021] |
|
||||||
|
|
||||||
## Coverage Notes
|
## Coverage Notes
|
||||||
|
|
||||||
- Reviewed so far: every artifact in the project (see the Last Reviewed column).
|
- 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.
|
- No ERD, KPI, BMC or BPMN exists yet. `-` in Downstream means nothing is built on the artifact yet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -52,6 +58,15 @@ updated whenever an artifact instance is created or reviewed.
|
|||||||
[MIL-001]: ../milestones/mil-001-foundation.md
|
[MIL-001]: ../milestones/mil-001-foundation.md
|
||||||
[MIL-002]: ../milestones/mil-002-repositories-and-mirror.md
|
[MIL-002]: ../milestones/mil-002-repositories-and-mirror.md
|
||||||
[MIL-003]: ../milestones/mil-003-scaffold-and-release.md
|
[MIL-003]: ../milestones/mil-003-scaffold-and-release.md
|
||||||
|
[MIL-004]: ../milestones/mil-004-configurable-details.md
|
||||||
|
[MIL-005]: ../milestones/mil-005-credentials.md
|
||||||
|
[MIL-006]: ../milestones/mil-006-project-license.md
|
||||||
|
[RC-018]: ./reviews/rc-018-mil-004.md
|
||||||
|
[RC-019]: ./reviews/rc-019-mil-004-code.md
|
||||||
|
[RC-020]: ./reviews/rc-020-mil-005.md
|
||||||
|
[RC-021]: ./reviews/rc-021-dcd.md
|
||||||
|
[DCD-001]: ../uc-001/dcd.md
|
||||||
|
[DCD-002]: ../dcd.md
|
||||||
[UCD-001]: ../use-case-diagram.md
|
[UCD-001]: ../use-case-diagram.md
|
||||||
[US-001]: ../user-stories.md
|
[US-001]: ../user-stories.md
|
||||||
[UC-001]: ../uc-001/uc.md
|
[UC-001]: ../uc-001/uc.md
|
||||||
@@ -77,4 +92,6 @@ updated whenever an artifact instance is created or reviewed.
|
|||||||
[RC-014]: ./reviews/rc-014-mil-002.md
|
[RC-014]: ./reviews/rc-014-mil-002.md
|
||||||
[RC-015]: ./reviews/rc-015-mil-003.md
|
[RC-015]: ./reviews/rc-015-mil-003.md
|
||||||
[RC-016]: ./reviews/rc-016-create-project-sh.md
|
[RC-016]: ./reviews/rc-016-create-project-sh.md
|
||||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
[RC-017]: ./reviews/rc-017-e2e-security-review.md
|
||||||
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
|
[d773fa9]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/d773fa91df5a54090254e12e074880fb6526a9ff
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
# Design Class Diagram (UC-001)
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | DCD-001 |
|
||||||
|
| CrossReference | [UC-001], [DM-001], [DM-002], [OC-001], [SD-001], [DICT-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-06 | Accepted | Jens Tirsvad Nielsen | S02 | Added CredentialCollector, EnvFileWriter and EnvFile; writeEnvFile parameter | [ded26a6] |
|
||||||
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | ProjectRequest carries the license that applies | [d773fa9] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose and Scope
|
||||||
|
|
||||||
|
Covers [UC-001] "Create a new project". It refines the concepts of [DM-001] into design classes and turns the messages of [SD-001] into method signatures, so that every method traces to a contract in [OC-001] or to a message in [SD-001]. Class and attribute names are the IT terms of [DICT-001]. The project-level model that consolidates all use cases is [DCD-002].
|
||||||
|
|
||||||
|
The classes are design classes of a Bash program: a class is a group of functions in `src/lib/` with its data held in the shared state arrays (see "Implementation Mapping"). There is no object-oriented runtime, but the responsibilities, associations and dependencies below are the ones the code keeps.
|
||||||
|
|
||||||
|
Failure handling (the exceptions of [OC-001]) is one rule of `ProjectCreator`, stop and report, and is not drawn.
|
||||||
|
|
||||||
|
## Diagram
|
||||||
|
|
||||||
|
```plantuml
|
||||||
|
@startuml
|
||||||
|
skinparam classAttributeIconSize 0
|
||||||
|
hide empty members
|
||||||
|
|
||||||
|
enum Visibility {
|
||||||
|
private
|
||||||
|
public
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProjectCreator <<controller>> {
|
||||||
|
+startProjectCreation() : PromptSet
|
||||||
|
+provideProjectDetails(name : String, description : String, visibility : Visibility, giteaOwner : Owner, githubOwner : Owner [0..1], directory : Path, enablePlanGate : Boolean, writeEnvFile : Boolean) : Summary
|
||||||
|
}
|
||||||
|
class ConfigLoader {
|
||||||
|
+load(configFile : Path, envFile : Path) : Configuration
|
||||||
|
}
|
||||||
|
class CredentialCollector {
|
||||||
|
+collect(configuration : Configuration, kinds : String [1..3]) : Configuration
|
||||||
|
}
|
||||||
|
class EnvFileWriter {
|
||||||
|
+write(project : LocalProject, configuration : Configuration, hasGithub : Boolean) : EnvFile [0..1]
|
||||||
|
}
|
||||||
|
class ToolChecker {
|
||||||
|
+check(tools : String [1..*]) : ToolCheck
|
||||||
|
}
|
||||||
|
class Preflight {
|
||||||
|
+check(request : ProjectRequest) : PreflightResult
|
||||||
|
}
|
||||||
|
abstract class GitHost <<facade>> {
|
||||||
|
-name : String
|
||||||
|
-webAddress : String
|
||||||
|
-apiAddress : String
|
||||||
|
+verifyToken() : Boolean
|
||||||
|
+ownerAccepts(owner : Owner) : Boolean
|
||||||
|
+nameFree(name : String) : Boolean
|
||||||
|
}
|
||||||
|
class GiteaClient <<facade>> {
|
||||||
|
+GiteaClient(configuration : Configuration)
|
||||||
|
+hasLicense(key : String) : Boolean
|
||||||
|
+createRepository(request : ProjectRequest, license : String [0..1]) : GiteaRepository
|
||||||
|
+addPushMirror(source : GiteaRepository, target : GitHubRepository) : PushMirror
|
||||||
|
-requestSync(mirror : PushMirror) : void
|
||||||
|
}
|
||||||
|
class GitHubClient <<facade>> {
|
||||||
|
+GitHubClient(configuration : Configuration)
|
||||||
|
+createEmptyRepository(request : ProjectRequest) : GitHubRepository
|
||||||
|
}
|
||||||
|
class LocalProjectBuilder {
|
||||||
|
+build(directory : Path, source : GiteaRepository, target : GitHubRepository [0..1], sshPassed : Boolean) : LocalProject
|
||||||
|
}
|
||||||
|
class FrameworkInstaller {
|
||||||
|
+install(project : LocalProject, enablePlanGate : Boolean) : InstallResult
|
||||||
|
}
|
||||||
|
class SummaryReport {
|
||||||
|
+compose(request : ProjectRequest) : Summary
|
||||||
|
}
|
||||||
|
|
||||||
|
class Run {
|
||||||
|
-isApply : Boolean
|
||||||
|
}
|
||||||
|
class Configuration {
|
||||||
|
-giteaUrl : String
|
||||||
|
-giteaApiUrl : String
|
||||||
|
-githubWebUrl : String
|
||||||
|
-githubApiUrl : String
|
||||||
|
-giteaSshPort : Integer
|
||||||
|
-mirrorInterval : String
|
||||||
|
-frameworkRepo : String
|
||||||
|
-presetDetails : Map [0..1]
|
||||||
|
}
|
||||||
|
class Credential {
|
||||||
|
-kind : String
|
||||||
|
-value : String
|
||||||
|
}
|
||||||
|
class ToolCheck {
|
||||||
|
-hasGit : Boolean
|
||||||
|
-hasCurl : Boolean
|
||||||
|
-hasJq : Boolean
|
||||||
|
}
|
||||||
|
class PromptSet {
|
||||||
|
-prompts : String [1..*]
|
||||||
|
}
|
||||||
|
class ProjectRequest {
|
||||||
|
-name : String
|
||||||
|
-description : String
|
||||||
|
-visibility : Visibility
|
||||||
|
-directory : Path
|
||||||
|
-enablePlanGate : Boolean
|
||||||
|
-license : String [0..1]
|
||||||
|
}
|
||||||
|
class Owner {
|
||||||
|
-name : String
|
||||||
|
-kind : String
|
||||||
|
}
|
||||||
|
class PreflightResult {
|
||||||
|
-tokensWork : Boolean
|
||||||
|
-ownersAccept : Boolean
|
||||||
|
-nameIsFree : Boolean
|
||||||
|
-licenseIsOffered : Boolean
|
||||||
|
-sshPassed : Boolean
|
||||||
|
}
|
||||||
|
abstract class Repository {
|
||||||
|
-name : String
|
||||||
|
-description : String
|
||||||
|
-visibility : Visibility
|
||||||
|
-address : String
|
||||||
|
}
|
||||||
|
class GiteaRepository
|
||||||
|
class GitHubRepository
|
||||||
|
class LicenseFile {
|
||||||
|
-key : String
|
||||||
|
}
|
||||||
|
class PushMirror {
|
||||||
|
-interval : String
|
||||||
|
-syncOnCommit : Boolean
|
||||||
|
}
|
||||||
|
class LocalProject {
|
||||||
|
-directory : Path
|
||||||
|
}
|
||||||
|
class Remote {
|
||||||
|
-name : String
|
||||||
|
-address : String
|
||||||
|
}
|
||||||
|
class Submodule {
|
||||||
|
-name : String
|
||||||
|
-address : String
|
||||||
|
}
|
||||||
|
class HookSetup {
|
||||||
|
-areSkillsInstalled : Boolean
|
||||||
|
-areHooksInstalled : Boolean
|
||||||
|
-isPlanGateEnabled : Boolean
|
||||||
|
}
|
||||||
|
class EnvFile {
|
||||||
|
-address : Path
|
||||||
|
-keys : String [1..3]
|
||||||
|
}
|
||||||
|
class Template {
|
||||||
|
-name : String
|
||||||
|
-isCopied : Boolean
|
||||||
|
}
|
||||||
|
class InstallResult <<dto>>
|
||||||
|
class Summary {
|
||||||
|
-createdItems : String [0..*]
|
||||||
|
-skippedItems : String [0..*]
|
||||||
|
-nextSteps : String [0..*]
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectCreator ..> ConfigLoader : creates
|
||||||
|
ProjectCreator ..> ToolChecker : creates
|
||||||
|
ProjectCreator ..> CredentialCollector : creates
|
||||||
|
ProjectCreator ..> EnvFileWriter : creates [0..1]
|
||||||
|
ProjectCreator ..> Preflight : creates
|
||||||
|
ProjectCreator ..> GiteaClient : creates
|
||||||
|
ProjectCreator ..> GitHubClient : creates [0..1]
|
||||||
|
ProjectCreator ..> LocalProjectBuilder : creates
|
||||||
|
ProjectCreator ..> FrameworkInstaller : creates
|
||||||
|
ProjectCreator ..> SummaryReport : creates
|
||||||
|
Preflight ..> GiteaClient : asks
|
||||||
|
Preflight ..> GitHubClient : asks [0..1]
|
||||||
|
GitHost <|-- GiteaClient
|
||||||
|
GitHost <|-- GitHubClient
|
||||||
|
GitHost "1" --> "0..*" Owner : has
|
||||||
|
GiteaClient ..> Configuration
|
||||||
|
GitHubClient ..> Configuration
|
||||||
|
|
||||||
|
ProjectCreator "0..*" --> "1" Run
|
||||||
|
Run "1" *-- "1" Configuration
|
||||||
|
Run "1" *-- "1" ToolCheck
|
||||||
|
Run "1" *-- "0..1" ProjectRequest
|
||||||
|
Run "1" --> "1" PromptSet : returns
|
||||||
|
Configuration "1" *-- "1..3" Credential
|
||||||
|
|
||||||
|
ProjectRequest "0..*" --> "1" Owner : giteaOwner
|
||||||
|
ProjectRequest "0..*" --> "0..1" Owner : githubOwner
|
||||||
|
ProjectRequest "1" *-- "0..1" PreflightResult
|
||||||
|
ProjectRequest "1" --> "0..1" GiteaRepository : stored in
|
||||||
|
ProjectRequest "1" --> "0..1" GitHubRepository : also stored in
|
||||||
|
ProjectRequest "1" --> "0..1" LocalProject : working copy
|
||||||
|
Summary "0..*" --> "1" ProjectRequest : reports on
|
||||||
|
|
||||||
|
Repository <|-- GiteaRepository
|
||||||
|
Repository <|-- GitHubRepository
|
||||||
|
Repository "0..*" --> "1" Owner : owned by
|
||||||
|
GiteaRepository "1" *-- "0..1" LicenseFile
|
||||||
|
PushMirror "0..*" --> "1" GiteaRepository : source
|
||||||
|
PushMirror "0..*" --> "1" GitHubRepository : target
|
||||||
|
PushMirror "0..*" --> "1" Credential : authorised by
|
||||||
|
|
||||||
|
LocalProject "1" *-- "1..2" Remote
|
||||||
|
Remote "0..*" --> "1" Repository : points to
|
||||||
|
LocalProject "1" *-- "1" Submodule
|
||||||
|
LocalProject "1" *-- "1" HookSetup
|
||||||
|
LocalProject "1" *-- "0..*" Template
|
||||||
|
LocalProject "1" *-- "0..1" EnvFile
|
||||||
|
EnvFile "0..*" --> "1..3" Credential : copy of
|
||||||
|
InstallResult "0..*" --> "1" Submodule
|
||||||
|
InstallResult "0..*" --> "1" HookSetup
|
||||||
|
InstallResult "0..*" --> "0..*" Template
|
||||||
|
|
||||||
|
ProjectRequest "0..*" --> "1" Visibility
|
||||||
|
Repository "0..*" --> "1" Visibility
|
||||||
|
@enduml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Class Table
|
||||||
|
|
||||||
|
| Class | Refines (Domain Model concept) | Responsibility | Attributes | Operations |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `ProjectCreator` | none (controller for the system operations of [OC-001]) | Receives the two system operations, sequences the steps and stops on the first failure. | none | `startProjectCreation`, `provideProjectDetails` |
|
||||||
|
| `ConfigLoader` | Configuration | Reads `config.env` and `.env` as plain text and validates every value, preset project details included. | none | `load` |
|
||||||
|
| `CredentialCollector` | none (system concept) | Asks, without echo, for a credential that `.env` does not provide and validates it like one read from `.env`. | none | `collect` |
|
||||||
|
| `EnvFileWriter` | Credentials File | Creates the project's own `.env` with the credentials the project needs: owner-only, excluded from git, never replaced without a yes. | none | `write` |
|
||||||
|
| `ToolChecker` | none (system concept `ToolCheck`) | Detects the required and optional tools. | none | `check` |
|
||||||
|
| `Preflight` | none (system concept `PreflightResult`) | Runs the read-only checks of both hosts before anything is created. | none | `check` |
|
||||||
|
| `GitHost` | Git Host | The operations every host offers: check the token, check that an owner accepts new repositories, check that a name is free. | `name`, `webAddress`, `apiAddress` | `verifyToken`, `ownerAccepts`, `nameFree` |
|
||||||
|
| `GiteaClient` | Git Host (Gitea) | Hides the Gitea API and its token; creates the repository and the push mirror. | none beyond `GitHost` (uses `Configuration`) | `GiteaClient`, `hasLicense`, `createRepository`, `addPushMirror`, `requestSync` |
|
||||||
|
| `GitHubClient` | Git Host (GitHub) | Hides the GitHub API and its token; creates the empty repository. | none beyond `GitHost` (uses `Configuration`) | `GitHubClient`, `createEmptyRepository` |
|
||||||
|
| `LocalProjectBuilder` | Local Project, Remote | Creates the project directory, its git repository and its credential-free remotes. | none | `build` |
|
||||||
|
| `FrameworkInstaller` | Framework, Framework Setup, Template | Adds the framework submodule, installs skills and hooks once, and copies the templates without overwriting. | none | `install` |
|
||||||
|
| `SummaryReport` | Summary | Composes the report of what was created, skipped or failed. | none | `compose` |
|
||||||
|
| `Run` | none (system concept) | Holds the state of one execution. | `isApply` | none |
|
||||||
|
| `Configuration` | Configuration | Holds the service addresses, the credentials and any preset project details. | `giteaUrl`, `giteaApiUrl`, `githubWebUrl`, `githubApiUrl`, `giteaSshPort`, `mirrorInterval`, `frameworkRepo`, `presetDetails` | none |
|
||||||
|
| `Credential` | Access Token | Holds a secret in memory only; it never becomes part of an address or a message. | `kind`, `value` | none |
|
||||||
|
| `ToolCheck` | none (system concept) | Records which tools are present. | `hasGit`, `hasCurl`, `hasJq` | none |
|
||||||
|
| `PromptSet` | none (system concept) | The questions still to ask; a detail preset in `config.env` is not in it. | `prompts` | none |
|
||||||
|
| `ProjectRequest` | Project | Holds the details of the project being created. | `name`, `description`, `visibility`, `directory`, `enablePlanGate`, `license` | none |
|
||||||
|
| `Owner` | Owner | A user or organization on a host. | `name`, `kind` | none |
|
||||||
|
| `PreflightResult` | none (system concept) | Records the outcome of the preflight checks. | `tokensWork`, `ownersAccept`, `nameIsFree`, `licenseIsOffered`, `sshPassed` | none |
|
||||||
|
| `Repository` | Repository | Common data of a repository on a host. | `name`, `description`, `visibility`, `address` | none |
|
||||||
|
| `GiteaRepository` | Gitea Repository | The source of truth. | none beyond `Repository` | none |
|
||||||
|
| `GitHubRepository` | GitHub Repository | Receives its content from the mirror. | none beyond `Repository` | none |
|
||||||
|
| `LicenseFile` | License | The license file in the Gitea repository when a license applies. | `key` | none |
|
||||||
|
| `PushMirror` | Mirror | The Gitea to GitHub push mirror. | `interval`, `syncOnCommit` | none |
|
||||||
|
| `LocalProject` | Local Project | The project directory on the Maintainer's machine. | `directory` | none |
|
||||||
|
| `Remote` | Remote | A named link to a repository (`origin`, `github`), without a credential. | `name`, `address` | none |
|
||||||
|
| `Submodule` | Framework | The framework added to the local project. | `name`, `address` | none |
|
||||||
|
| `HookSetup` | Framework Setup | Records the skills and hooks installed and the plan gate state. | `areSkillsInstalled`, `areHooksInstalled`, `isPlanGateEnabled` | none |
|
||||||
|
| `EnvFile` | Credentials File | The `.env` of the project: a copy of the credentials it needs. | `address`, `keys` | none |
|
||||||
|
| `Template` | Template | A framework file copied into the project. | `name`, `isCopied` | none |
|
||||||
|
| `InstallResult` | none (carries the result of one operation) | Returns the submodule, the hook setup and the templates of `install`. | none | none |
|
||||||
|
| `Summary` | Summary | The report returned to the Maintainer; it contains no credential. | `createdItems`, `skippedItems`, `nextSteps` | none |
|
||||||
|
| `Visibility` | none (enumeration of a Project and Repository attribute) | The two allowed visibilities. | `private`, `public` | none |
|
||||||
|
|
||||||
|
## Method Traceability
|
||||||
|
|
||||||
|
| Method signature | Operation Contract / SD message |
|
||||||
|
| --- | --- |
|
||||||
|
| `ProjectCreator.startProjectCreation() : PromptSet` | [OC-001] `startProjectCreation`; [SD-001] `startProjectCreation()` |
|
||||||
|
| `ProjectCreator.provideProjectDetails(name, description, visibility, giteaOwner, githubOwner, directory, enablePlanGate, writeEnvFile) : Summary` | [OC-001] `provideProjectDetails`; [SD-001] `provideProjectDetails(...)` |
|
||||||
|
| `ConfigLoader.load(configFile, envFile) : Configuration` | [SD-001] `load(config.env, .env)`; [OC-001] `startProjectCreation` P2 |
|
||||||
|
| `CredentialCollector.collect(configuration, kinds) : Configuration` | [SD-001] `collect(configuration, GITEA_TOKEN)` and `collect(configuration, GITHUB_PAT, GITHUB_USER)`; [OC-001] `startProjectCreation` P2 and the precondition of `provideProjectDetails` |
|
||||||
|
| `EnvFileWriter.write(project, configuration, hasGithub) : EnvFile` | [SD-001] `write(localProject, configuration, githubOwner present)`; [OC-001] `provideProjectDetails` P14 |
|
||||||
|
| `ToolChecker.check(tools) : ToolCheck` | [SD-001] `check(git, curl, jq)`; [OC-001] `startProjectCreation` P3 |
|
||||||
|
| `Preflight.check(request) : PreflightResult` | [SD-001] `check(request)`; [OC-001] `provideProjectDetails` P2 |
|
||||||
|
| `GiteaClient(configuration)` | [SD-001] `new(configuration)` to `GiteaClient` |
|
||||||
|
| `GitHost.verifyToken() : Boolean` | [SD-001] `verifyToken()` from `Preflight` to either client; P2 |
|
||||||
|
| `GitHost.ownerAccepts(owner) : Boolean` | [SD-001] `ownerAccepts(giteaOwner)` and `ownerAccepts(githubOwner)`; P2 |
|
||||||
|
| `GitHost.nameFree(name) : Boolean` | [SD-001] `nameFree(name)` to either client; P2 |
|
||||||
|
| `GiteaClient.hasLicense(key) : Boolean` | [SD-001] `hasLicense(license)`; P2 |
|
||||||
|
| `GiteaClient.createRepository(request, license) : GiteaRepository` | [SD-001] `createRepository(request, license)`; P3, P4 |
|
||||||
|
| `GiteaClient.addPushMirror(source, target) : PushMirror` | [SD-001] `addPushMirror(giteaRepository, gitHubRepository)`; P6 |
|
||||||
|
| `GiteaClient.requestSync(mirror) : void` | [SD-001] `requestSync(pushMirror)`; P6 |
|
||||||
|
| `GitHubClient(configuration)` | [SD-001] `new(configuration)` to `GitHubClient` |
|
||||||
|
| `GitHubClient.createEmptyRepository(request) : GitHubRepository` | [SD-001] `createEmptyRepository(request)`; P5 |
|
||||||
|
| `LocalProjectBuilder.build(directory, source, target, sshPassed) : LocalProject` | [SD-001] `build(directory, giteaRepository, gitHubRepository, sshPassed)`; P7, P8, P9 |
|
||||||
|
| `FrameworkInstaller.install(project, enablePlanGate) : InstallResult` | [SD-001] `install(localProject, enablePlanGate)`; P10, P11, P12 |
|
||||||
|
| `SummaryReport.compose(request) : Summary` | [SD-001] `compose(projectRequest)`; P13 |
|
||||||
|
|
||||||
|
## Pattern Annotations
|
||||||
|
|
||||||
|
| Pattern | Classes | Rationale |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Controller (GRASP) | `ProjectCreator` | One entry for the system operations; coordinates and does no HTTP, git or file work itself |
|
||||||
|
| Facade (GoF) | `GitHost`, `GiteaClient`, `GitHubClient` | Each client hides one host's HTTP API and keeps the token inside; no other class sees a credential. `GitHost` holds the operations both share |
|
||||||
|
| Pure Fabrication (GRASP) | `ConfigLoader`, `ToolChecker`, `CredentialCollector`, `EnvFileWriter`, `Preflight`, `LocalProjectBuilder`, `FrameworkInstaller`, `SummaryReport` | No domain concept owns these responsibilities; small units keep cohesion high |
|
||||||
|
| Creator (GRASP) | `ConfigLoader` creates `Configuration`; `GiteaClient` creates `GiteaRepository` and `PushMirror` | The creating class holds the data needed to build the object |
|
||||||
|
| Protection from variations (GRASP) | `GiteaClient`, `GitHubClient`, `ProjectRequest` | The optional GitHub path is decided by the controller; the clients do not know it |
|
||||||
|
| Data Transfer Object (GoF-style) | `InstallResult` | Carries the three results of `install` in one return value |
|
||||||
|
|
||||||
|
## Dependency Check
|
||||||
|
|
||||||
|
No circular dependency. `ProjectCreator` depends on every helper class and no helper depends on it. `Preflight` depends on the two clients; the clients extend `GitHost` and depend only on `Configuration`. The data classes form a tree: `Run` holds `Configuration`, `ToolCheck` and `ProjectRequest`; `ProjectRequest` reaches the repositories and the `LocalProject`; `Summary` points at `ProjectRequest` and nothing points back at it. `CredentialCollector` and `EnvFileWriter` depend only on `Configuration`, `Credential` and `LocalProject`; the only class that holds a secret after the run is `EnvFile`, and only as a copy written to the Maintainer's own disk. `Repository` is shared by `Remote` and `PushMirror` without a cycle.
|
||||||
|
|
||||||
|
SOLID check: no class has more than one reason to change (one host API, one kind of local work, one report); the clients can be replaced behind the same operations; the controller depends on the operations, not on how a host or git is called. `ProjectCreator` has two operations and no data, so it is not a god class.
|
||||||
|
|
||||||
|
## Implementation Mapping
|
||||||
|
|
||||||
|
| Design class | Where it lives in `src/` |
|
||||||
|
| --- | --- |
|
||||||
|
| `ProjectCreator` | `create-project.sh` (`main`), `lib/apply.sh` |
|
||||||
|
| `ConfigLoader` | `lib/config.sh` (`load_configuration`), `lib/validate.sh` |
|
||||||
|
| `ToolChecker` | `lib/tools.sh` |
|
||||||
|
| `CredentialCollector` | planned for [MIL-005]: `lib/credentials.sh`, with `lib/prompts.sh` |
|
||||||
|
| `EnvFileWriter` | planned for [MIL-005]: `lib/envfile.sh` |
|
||||||
|
| `Preflight` | `lib/preflight.sh` |
|
||||||
|
| `GitHost`, `GiteaClient`, `GitHubClient` | `lib/api.sh`, `lib/http.sh`, `lib/json.sh`, `lib/repositories.sh`, `lib/mirror.sh`, `lib/hosts.sh` |
|
||||||
|
| `LocalProjectBuilder` | `lib/localproject.sh`, `lib/git.sh` |
|
||||||
|
| `FrameworkInstaller` | `lib/framework.sh` |
|
||||||
|
| `SummaryReport` | `lib/steps.sh`, `lib/plan.sh` |
|
||||||
|
| `PromptSet`, `ProjectRequest` | `lib/project.sh`, `lib/prompts.sh` |
|
||||||
|
| `Run`, `Configuration`, `Credential`, `PreflightResult` | the state arrays declared in `lib/constants.sh` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[UC-001]: ./uc.md
|
||||||
|
[DM-001]: ./dm.md
|
||||||
|
[DM-002]: ../domain-model.md
|
||||||
|
[OC-001]: ./oc.md
|
||||||
|
[SD-001]: ./sd.md
|
||||||
|
[MIL-005]: ../milestones/mil-005-credentials.md
|
||||||
|
[DICT-001]: ../dictionary.md
|
||||||
|
[DCD-002]: ../dcd.md
|
||||||
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
|
[d773fa9]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/d773fa91df5a54090254e12e074880fb6526a9ff
|
||||||
+18
-6
@@ -9,7 +9,8 @@
|
|||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
| 2026-10-06 | Accepted | Jens Tirsvad Nielsen | S02 | Added Credentials File (a Local Project may have one) | [ded26a6] |
|
||||||
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | License applies when configured, not only when GitHub is chosen | [d773fa9] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,7 +32,9 @@ class Project {
|
|||||||
description
|
description
|
||||||
visibility
|
visibility
|
||||||
}
|
}
|
||||||
class Configuration
|
class Configuration {
|
||||||
|
preset project details
|
||||||
|
}
|
||||||
class "Git Host" as GitHost {
|
class "Git Host" as GitHost {
|
||||||
name
|
name
|
||||||
web address
|
web address
|
||||||
@@ -76,6 +79,9 @@ class "Framework Setup" as FrameworkSetup {
|
|||||||
class Template {
|
class Template {
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
|
class "Credentials File" as CredentialsFile {
|
||||||
|
address
|
||||||
|
}
|
||||||
class Summary {
|
class Summary {
|
||||||
created items
|
created items
|
||||||
skipped items
|
skipped items
|
||||||
@@ -105,6 +111,8 @@ LocalProject "1" --> "1" FrameworkSetup : has
|
|||||||
FrameworkSetup "0..*" --> "1" Framework : is installed from
|
FrameworkSetup "0..*" --> "1" Framework : is installed from
|
||||||
Framework "1" --> "1..*" Template : provides
|
Framework "1" --> "1..*" Template : provides
|
||||||
LocalProject "1" --> "0..*" Template : contains a copy of
|
LocalProject "1" --> "0..*" Template : contains a copy of
|
||||||
|
LocalProject "1" --> "0..1" CredentialsFile : has
|
||||||
|
CredentialsFile "1" --> "1..2" AccessToken : holds a copy of
|
||||||
Summary "1" --> "1" Project : reports on
|
Summary "1" --> "1" Project : reports on
|
||||||
@enduml
|
@enduml
|
||||||
```
|
```
|
||||||
@@ -115,20 +123,21 @@ Summary "1" --> "1" Project : reports on
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Maintainer | The person who creates a new project (S01 or S02) | name | [UC-001] primary actor |
|
| 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 |
|
| 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" |
|
| Configuration | The service addresses and access tokens the Maintainer has set up before starting, and any project details preset in it | preset project details (optional) | [UC-001] precondition, steps 2 and 3 |
|
||||||
| Git Host | A service that holds repositories: Gitea or GitHub | name, web address, API address | [UC-001] steps 5 to 7 "GitHub", "Gitea" |
|
| 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" |
|
| 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" |
|
| 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" |
|
| 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 |
|
| 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 |
|
| 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" |
|
| License | The legal terms file added to a Gitea Repository when a license applies: the one set in the Configuration, or AGPL-3.0 when GitHub is chosen and none is set | name | [UC-001] step 6 "license" |
|
||||||
| Mirror | The push mirror that copies a Gitea Repository to a GitHub Repository | interval, sync on commit | [UC-001] step 7 "push mirror" |
|
| 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" |
|
| 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" |
|
| 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 | 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" |
|
| 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" |
|
| Template | A file the Framework provides to copy into a project (`AGENTS.md`, artifact registry) | name | [UC-001] step 9 "templates" |
|
||||||
|
| Credentials File | The file in a Local Project that holds a copy of the Access Tokens (and the GitHub account name) the project needs; readable by its owner only and ignored by git | address | [UC-001] step 9 "credentials file" |
|
||||||
| 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" |
|
| 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
|
## Association Table
|
||||||
@@ -143,7 +152,7 @@ Summary "1" --> "1" Project : reports on
|
|||||||
| Owner | owns | Repository | 1 to 0..* |
|
| Owner | owns | Repository | 1 to 0..* |
|
||||||
| Project | is stored in | Gitea Repository | 1 to 1 |
|
| Project | is stored in | Gitea Repository | 1 to 1 |
|
||||||
| Project | is also stored in | GitHub Repository | 1 to 0..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) |
|
| Gitea Repository | has | License | 1 to 0..1 (1 when a license applies) |
|
||||||
| Mirror | copies from | Gitea Repository | 1 to 1 |
|
| Mirror | copies from | Gitea Repository | 1 to 1 |
|
||||||
| Mirror | copies to | GitHub Repository | 1 to 1 |
|
| Mirror | copies to | GitHub Repository | 1 to 1 |
|
||||||
| Mirror | is authorised by | Access Token | 0..* to 1 |
|
| Mirror | is authorised by | Access Token | 0..* to 1 |
|
||||||
@@ -155,6 +164,8 @@ Summary "1" --> "1" Project : reports on
|
|||||||
| Framework Setup | is installed from | Framework | 0..* to 1 |
|
| Framework Setup | is installed from | Framework | 0..* to 1 |
|
||||||
| Framework | provides | Template | 1 to 1..* |
|
| Framework | provides | Template | 1 to 1..* |
|
||||||
| Local Project | contains a copy of | Template | 1 to 0..* |
|
| Local Project | contains a copy of | Template | 1 to 0..* |
|
||||||
|
| Local Project | has | Credentials File | 1 to 0..1 |
|
||||||
|
| Credentials File | holds a copy of | Access Token | 1 to 1..2 |
|
||||||
| Summary | reports on | Project | 1 to 1 |
|
| Summary | reports on | Project | 1 to 1 |
|
||||||
|
|
||||||
## Generalizations
|
## Generalizations
|
||||||
@@ -169,4 +180,5 @@ Summary "1" --> "1" Project : reports on
|
|||||||
[SSD-001]: ./ssd.md
|
[SSD-001]: ./ssd.md
|
||||||
[DICT-001]: ../dictionary.md
|
[DICT-001]: ../dictionary.md
|
||||||
[DM-002]: ../domain-model.md
|
[DM-002]: ../domain-model.md
|
||||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
|
[d773fa9]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/d773fa91df5a54090254e12e074880fb6526a9ff
|
||||||
|
|||||||
+18
-10
@@ -9,7 +9,8 @@
|
|||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
| 2026-10-06 | Accepted | Jens Tirsvad Nielsen | S02 | Credentials asked when missing; EnvFile created in the local project (P14); writeEnvFile parameter | [ded26a6] |
|
||||||
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | P2, P4 and an exception: the license that applies, not always AGPL-3.0 | [d773fa9] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ Concepts below use the IT terms of [DICT-001] for the PO concepts of [DM-001]. `
|
|||||||
**Postconditions**
|
**Postconditions**
|
||||||
|
|
||||||
- P1. A `Run` instance was created.
|
- 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.
|
- P2. A `Configuration` instance was created from `config.env` and `.env`, with every value validated (including the project details preset in `config.env`). A `Credential` that `.env` did not provide was entered by the Maintainer without echo and validated; every `Credential` is 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.
|
- 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.
|
- P4. The `Run` was associated with a `PromptSet` that is returned.
|
||||||
|
|
||||||
@@ -38,28 +39,32 @@ Concepts below use the IT terms of [DICT-001] for the PO concepts of [DM-001]. `
|
|||||||
|
|
||||||
| Condition (failing precondition) | Outcome |
|
| 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 |
|
| `config.env` is missing, or a value in `config.env` or `.env` is malformed (a preset project detail included) | The `Run` ends with an error naming the key, never its value; nothing was changed |
|
||||||
|
| A `Credential` is missing and input ends before a valid one is entered | The `Run` ends with an error naming the key; nothing was changed |
|
||||||
| `git` or `curl` is missing | The `Run` ends with an error naming the tool; nothing was changed |
|
| `git` or `curl` is missing | The `Run` ends with an error naming the tool; nothing was changed |
|
||||||
|
|
||||||
## Contract: provideProjectDetails
|
## Contract: provideProjectDetails
|
||||||
|
|
||||||
| Item | Value |
|
| Item | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Operation | `provideProjectDetails(name: String, description: String, visibility: Visibility, giteaOwner: Owner, githubOwner: Owner [0..1], directory: Path, enablePlanGate: Boolean): Summary` |
|
| Operation | `provideProjectDetails(name: String, description: String, visibility: Visibility, giteaOwner: Owner, githubOwner: Owner [0..1], directory: Path, enablePlanGate: Boolean, writeEnvFile: Boolean): Summary` |
|
||||||
| Traces to | `provideProjectDetails` in [SSD-001] |
|
| Traces to | `provideProjectDetails` in [SSD-001] |
|
||||||
| Concepts | ProjectRequest, PreflightResult, GiteaRepository, GitHubRepository, LicenseFile, PushMirror, LocalProject, Remote, Submodule, HookSetup, Summary |
|
| Concepts | ProjectRequest, PreflightResult, GiteaRepository, GitHubRepository, LicenseFile, PushMirror, LocalProject, Remote, Submodule, HookSetup, EnvFile, Summary |
|
||||||
|
|
||||||
**Preconditions**
|
**Preconditions**
|
||||||
|
|
||||||
- A `Run` exists and its `Configuration` is valid (from `startProjectCreation`).
|
- A `Run` exists and its `Configuration` is valid (from `startProjectCreation`).
|
||||||
- `githubOwner` is present exactly when the Maintainer chose GitHub.
|
- `githubOwner` is present exactly when the Maintainer chose GitHub.
|
||||||
|
- The license that applies is not asked: it comes from the `Configuration` (`PROJECT_LICENSE`) or follows the GitHub choice.
|
||||||
|
- When `githubOwner` is present, the GitHub `Credential`s are known: from `.env`, or entered by the Maintainer without echo and validated before the first request.
|
||||||
|
- A detail that the `Configuration` defines is not asked: it is taken from the `Configuration`.
|
||||||
|
|
||||||
**Postconditions**
|
**Postconditions**
|
||||||
|
|
||||||
- P1. A `ProjectRequest` instance was created with the given attributes and associated with the `Run`.
|
- P1. A `ProjectRequest` instance was created with the attributes given by the Maintainer or defined by the `Configuration`, 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.
|
- 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 the license that applies is offered by Gitea (when a license applies), 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`.
|
- 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.
|
- P4. If a license applies, a `LicenseFile` instance for it was created and associated with the `GiteaRepository`, so that repository is not empty. The license that applies is the one the `Configuration` defines (`PROJECT_LICENSE`; `none` means none), otherwise `AGPL-3.0` if `githubOwner` is present, otherwise none. If no license applies 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`.
|
- 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.
|
- 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.
|
- 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.
|
||||||
@@ -69,16 +74,18 @@ Concepts below use the IT terms of [DICT-001] for the PO concepts of [DM-001]. `
|
|||||||
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
- P14. If `writeEnvFile`, an `EnvFile` named `.env` was associated with the `LocalProject`, holding only the `Credential`s the project needs (the Gitea token, and the GitHub token and account name when `githubOwner` is present). It is readable by its owner only and excluded from git without a change to any tracked file, and no `Credential` is shown in any output. If `writeEnvFile` is false, no `EnvFile` was created. An existing `.env` is left as it was unless the Maintainer agreed to replace it.
|
||||||
|
|
||||||
**Exceptions**
|
**Exceptions**
|
||||||
|
|
||||||
| Condition (failing precondition) | Outcome |
|
| 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 |
|
| 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 |
|
| A license applies and Gitea does not offer it (P2) | The `Run` ends before P3; nothing was created; the error names the license |
|
||||||
| `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 |
|
| `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 |
|
| `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` |
|
| `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 `.env` already exists in the `LocalProject` and the Maintainer declines replacing it (P14) | 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` |
|
| 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 |
|
| 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 |
|
||||||
|
|
||||||
@@ -88,4 +95,5 @@ Concepts below use the IT terms of [DICT-001] for the PO concepts of [DM-001]. `
|
|||||||
[DM-001]: ./dm.md
|
[DM-001]: ./dm.md
|
||||||
[DICT-001]: ../dictionary.md
|
[DICT-001]: ../dictionary.md
|
||||||
[SD-001]: ./sd.md
|
[SD-001]: ./sd.md
|
||||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
|
[d773fa9]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/d773fa91df5a54090254e12e074880fb6526a9ff
|
||||||
|
|||||||
+56
-26
@@ -4,16 +4,17 @@
|
|||||||
| Key | Value |
|
| Key | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| ID | SD-001 |
|
| ID | SD-001 |
|
||||||
| CrossReference | [OC-001] |
|
| CrossReference | [OC-001], [DCD-001] |
|
||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
| 2026-10-06 | Accepted | Jens Tirsvad Nielsen | S02 | Added CredentialCollector and EnvFileWriter and their messages (P2, P14) | [ded26a6] |
|
||||||
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | The license that applies is passed to hasLicense and createRepository; no alt on the GitHub choice | [d773fa9] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Design objects are conceptual; in `create-project.sh` each becomes a small function group. No Design Class Diagram exists yet.
|
Design objects are conceptual; in `create-project.sh` each becomes a small function group. [DCD-001] gives each object its class and turns each message below into a method signature.
|
||||||
|
|
||||||
## Sequence: startProjectCreation
|
## Sequence: startProjectCreation
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ actor Maintainer
|
|||||||
participant ":ProjectCreator" as PC
|
participant ":ProjectCreator" as PC
|
||||||
participant ":ConfigLoader" as CL
|
participant ":ConfigLoader" as CL
|
||||||
participant ":ToolChecker" as TC
|
participant ":ToolChecker" as TC
|
||||||
|
participant ":CredentialCollector" as CC
|
||||||
|
|
||||||
Maintainer -> PC : startProjectCreation()
|
Maintainer -> PC : startProjectCreation()
|
||||||
activate PC
|
activate PC
|
||||||
@@ -35,6 +37,11 @@ PC -> CL : load(config.env, .env)
|
|||||||
activate CL
|
activate CL
|
||||||
CL --> PC : configuration
|
CL --> PC : configuration
|
||||||
deactivate CL
|
deactivate CL
|
||||||
|
create CC
|
||||||
|
PC -> CC : collect(configuration, GITEA_TOKEN)
|
||||||
|
activate CC
|
||||||
|
CC --> PC : configuration
|
||||||
|
deactivate CC
|
||||||
create TC
|
create TC
|
||||||
PC -> TC : check(git, curl, jq)
|
PC -> TC : check(git, curl, jq)
|
||||||
activate TC
|
activate TC
|
||||||
@@ -43,6 +50,7 @@ deactivate TC
|
|||||||
PC --> Maintainer : promptSet
|
PC --> Maintainer : promptSet
|
||||||
deactivate PC
|
deactivate PC
|
||||||
destroy CL
|
destroy CL
|
||||||
|
destroy CC
|
||||||
destroy TC
|
destroy TC
|
||||||
@enduml
|
@enduml
|
||||||
```
|
```
|
||||||
@@ -52,7 +60,7 @@ destroy TC
|
|||||||
| Pattern (GRASP / GoF) | Applied to | Rationale |
|
| Pattern (GRASP / GoF) | Applied to | Rationale |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Controller (GRASP) | `ProjectCreator` | Receives the system operations and coordinates, without doing the work itself |
|
| 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 |
|
| Pure Fabrication (GRASP) | `ConfigLoader`, `ToolChecker`, `CredentialCollector` | No domain concept owns parsing, tool checks or asking for a credential; separate small units keep cohesion high |
|
||||||
| Creator (GRASP) | `ConfigLoader` creates `Configuration` | It holds the data needed to build and validate it |
|
| Creator (GRASP) | `ConfigLoader` creates `Configuration` | It holds the data needed to build and validate it |
|
||||||
|
|
||||||
### Postcondition Coverage
|
### Postcondition Coverage
|
||||||
@@ -60,13 +68,13 @@ destroy TC
|
|||||||
| Postcondition (from contract) | Satisfied by message |
|
| Postcondition (from contract) | Satisfied by message |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| P1 Run created | `startProjectCreation` received by `ProjectCreator` |
|
| P1 Run created | `startProjectCreation` received by `ProjectCreator` |
|
||||||
| P2 Configuration created and validated | `load(config.env, .env)` |
|
| P2 Configuration created and validated; a missing credential entered, validated and held in memory | `load(config.env, .env)` and `collect(configuration, GITEA_TOKEN)` |
|
||||||
| P3 ToolCheck created | `check(git, curl, jq)` |
|
| P3 ToolCheck created | `check(git, curl, jq)` |
|
||||||
| P4 PromptSet returned | `promptSet` return to the Maintainer |
|
| P4 PromptSet returned | `promptSet` return to the Maintainer |
|
||||||
|
|
||||||
### Responsibility Check
|
### Responsibility Check
|
||||||
|
|
||||||
`ProjectCreator` only sequences two calls; parsing and validation sit in `ConfigLoader`, tool detection in `ToolChecker`. No object receives every message.
|
`ProjectCreator` only sequences three calls; parsing and validation sit in `ConfigLoader`, asking for a missing credential in `CredentialCollector`, tool detection in `ToolChecker`. No object receives every message. Project details preset in `config.env` are read and validated by `ConfigLoader` as part of `configuration`; the second sequence is unchanged, because `provideProjectDetails` receives the same arguments whether they were asked or preset.
|
||||||
|
|
||||||
## Sequence: provideProjectDetails
|
## Sequence: provideProjectDetails
|
||||||
|
|
||||||
@@ -84,8 +92,10 @@ participant ":GitHubClient" as GH
|
|||||||
participant ":LocalProjectBuilder" as LB
|
participant ":LocalProjectBuilder" as LB
|
||||||
participant ":FrameworkInstaller" as FI
|
participant ":FrameworkInstaller" as FI
|
||||||
participant ":SummaryReport" as SR
|
participant ":SummaryReport" as SR
|
||||||
|
participant ":CredentialCollector" as CC
|
||||||
|
participant ":EnvFileWriter" as EW
|
||||||
|
|
||||||
Maintainer -> PC : provideProjectDetails(name, description, visibility, giteaOwner, githubOwner, directory, enablePlanGate)
|
Maintainer -> PC : provideProjectDetails(name, description, visibility, giteaOwner, githubOwner, directory, enablePlanGate, writeEnvFile)
|
||||||
activate PC
|
activate PC
|
||||||
create GT
|
create GT
|
||||||
PC -> GT : new(configuration)
|
PC -> GT : new(configuration)
|
||||||
@@ -93,10 +103,20 @@ opt githubOwner present
|
|||||||
create GH
|
create GH
|
||||||
PC -> GH : new(configuration)
|
PC -> GH : new(configuration)
|
||||||
end
|
end
|
||||||
|
opt githubOwner present
|
||||||
|
create CC
|
||||||
|
PC -> CC : collect(configuration, GITHUB_PAT, GITHUB_USER)
|
||||||
|
activate CC
|
||||||
|
CC --> PC : configuration
|
||||||
|
deactivate CC
|
||||||
|
end
|
||||||
create PF
|
create PF
|
||||||
PC -> PF : check(request)
|
PC -> PF : check(request)
|
||||||
activate PF
|
activate PF
|
||||||
PF -> GT : verifyToken(), ownerAccepts(giteaOwner), nameFree(name), hasLicense(AGPL-3.0)
|
PF -> GT : verifyToken(), ownerAccepts(giteaOwner), nameFree(name)
|
||||||
|
opt a license applies
|
||||||
|
PF -> GT : hasLicense(license)
|
||||||
|
end
|
||||||
opt githubOwner present
|
opt githubOwner present
|
||||||
PF -> GH : verifyToken(), ownerAccepts(githubOwner), nameFree(name)
|
PF -> GH : verifyToken(), ownerAccepts(githubOwner), nameFree(name)
|
||||||
end
|
end
|
||||||
@@ -104,17 +124,13 @@ PF --> PC : preflightResult
|
|||||||
deactivate PF
|
deactivate PF
|
||||||
|
|
||||||
opt githubOwner present
|
opt githubOwner present
|
||||||
PC -> GH : createEmptyRepository(githubOwner, name)
|
PC -> GH : createEmptyRepository(request)
|
||||||
activate GH
|
activate GH
|
||||||
GH --> PC : gitHubRepository
|
GH --> PC : gitHubRepository
|
||||||
deactivate GH
|
deactivate GH
|
||||||
end
|
end
|
||||||
|
|
||||||
alt githubOwner present
|
PC -> GT : createRepository(request, license)
|
||||||
PC -> GT : createRepository(giteaOwner, name, license=AGPL-3.0)
|
|
||||||
else no GitHub
|
|
||||||
PC -> GT : createRepository(giteaOwner, name, license=none)
|
|
||||||
end
|
|
||||||
activate GT
|
activate GT
|
||||||
GT --> PC : giteaRepository
|
GT --> PC : giteaRepository
|
||||||
deactivate GT
|
deactivate GT
|
||||||
@@ -122,7 +138,7 @@ deactivate GT
|
|||||||
opt githubOwner present
|
opt githubOwner present
|
||||||
PC -> GT : addPushMirror(giteaRepository, gitHubRepository)
|
PC -> GT : addPushMirror(giteaRepository, gitHubRepository)
|
||||||
activate GT
|
activate GT
|
||||||
GT -> GT : requestSync()
|
GT -> GT : requestSync(pushMirror)
|
||||||
GT --> PC : pushMirror
|
GT --> PC : pushMirror
|
||||||
deactivate GT
|
deactivate GT
|
||||||
end
|
end
|
||||||
@@ -136,11 +152,19 @@ deactivate LB
|
|||||||
create FI
|
create FI
|
||||||
PC -> FI : install(localProject, enablePlanGate)
|
PC -> FI : install(localProject, enablePlanGate)
|
||||||
activate FI
|
activate FI
|
||||||
FI --> PC : submodule, hookSetup, templates
|
FI --> PC : installResult
|
||||||
deactivate FI
|
deactivate FI
|
||||||
|
|
||||||
|
opt writeEnvFile
|
||||||
|
create EW
|
||||||
|
PC -> EW : write(localProject, configuration, githubOwner present)
|
||||||
|
activate EW
|
||||||
|
EW --> PC : envFile
|
||||||
|
deactivate EW
|
||||||
|
end
|
||||||
|
|
||||||
create SR
|
create SR
|
||||||
PC -> SR : compose(all results)
|
PC -> SR : compose(request)
|
||||||
SR --> PC : summary
|
SR --> PC : summary
|
||||||
PC --> Maintainer : summary
|
PC --> Maintainer : summary
|
||||||
deactivate PC
|
deactivate PC
|
||||||
@@ -149,6 +173,8 @@ destroy GT
|
|||||||
destroy GH
|
destroy GH
|
||||||
destroy LB
|
destroy LB
|
||||||
destroy FI
|
destroy FI
|
||||||
|
destroy CC
|
||||||
|
destroy EW
|
||||||
destroy SR
|
destroy SR
|
||||||
@enduml
|
@enduml
|
||||||
```
|
```
|
||||||
@@ -158,7 +184,7 @@ destroy SR
|
|||||||
| Pattern (GRASP / GoF) | Applied to | Rationale |
|
| Pattern (GRASP / GoF) | Applied to | Rationale |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Controller (GRASP) | `ProjectCreator` | Single entry for the system operation; sequences the steps and stops on the first failure |
|
| 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 |
|
| Pure Fabrication (GRASP) | `Preflight`, `LocalProjectBuilder`, `FrameworkInstaller`, `SummaryReport`, `CredentialCollector`, `EnvFileWriter` | 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
||||||
@@ -167,24 +193,28 @@ destroy SR
|
|||||||
| Postcondition (from contract) | Satisfied by message |
|
| Postcondition (from contract) | Satisfied by message |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| P1 ProjectRequest created | `provideProjectDetails` received by `ProjectCreator` |
|
| P1 ProjectRequest created | `provideProjectDetails` received by `ProjectCreator` |
|
||||||
| P2 PreflightResult created | `check(request)` |
|
| P2 PreflightResult created, including that the license is offered | `check(request)` and `hasLicense(license)` |
|
||||||
| P3 GiteaRepository created | `createRepository(giteaOwner, name, license)` |
|
| P3 GiteaRepository created | `createRepository(request, license)` |
|
||||||
| P4 LicenseFile when GitHub chosen, otherwise empty | `createRepository(..., license=AGPL-3.0)` and the `alt` branch `license=none` |
|
| P4 LicenseFile when a license applies, otherwise empty | `createRepository(request, license)`; `license` is the one the `ProjectRequest` carries (`PROJECT_LICENSE`, or AGPL-3.0 when GitHub was chosen), and is absent for `none` |
|
||||||
| P5 empty GitHubRepository when chosen | `createEmptyRepository(githubOwner, name)` |
|
| P2 GitHub credentials known before the first request | `collect(configuration, GITHUB_PAT, GITHUB_USER)` inside `opt githubOwner present` |
|
||||||
| P6 PushMirror and first sync | `addPushMirror(...)` and `requestSync()` |
|
| P5 empty GitHubRepository when chosen | `createEmptyRepository(request)` |
|
||||||
|
| P6 PushMirror and first sync | `addPushMirror(...)` and `requestSync(pushMirror)` |
|
||||||
| P7 LocalProject created, history from Gitea when not empty | `build(directory, ...)` |
|
| P7 LocalProject created, history from Gitea when not empty | `build(directory, ...)` |
|
||||||
| P8 origin remote (SSH if the test passed, else HTTPS) | `build(..., sshPassed)` |
|
| P8 origin remote (SSH if the test passed, else HTTPS) | `build(..., sshPassed)` |
|
||||||
| P9 github remote when chosen | `build(...)` |
|
| P9 github remote when chosen | `build(...)` |
|
||||||
| P10 framework Submodule | `install(localProject, ...)` |
|
| P10 framework Submodule | `install(localProject, ...)` |
|
||||||
| P11 HookSetup, plan gate if chosen | `install(localProject, enablePlanGate)` |
|
| P11 HookSetup, plan gate if chosen | `install(localProject, enablePlanGate)` |
|
||||||
| P12 AGENTS.md and registry copied or kept | `install(...)` returning `templates` |
|
| P12 AGENTS.md and registry copied or kept | `install(...)` returning `templates` |
|
||||||
| P13 Summary created and returned | `compose(all results)` and the final return |
|
| P13 Summary created and returned | `compose(request)` and the final return |
|
||||||
|
| P14 EnvFile created with the needed credentials, owner-only, ignored by git, or none when declined | `write(localProject, configuration, githubOwner present)` inside `opt writeEnvFile` |
|
||||||
|
|
||||||
### Responsibility Check
|
### 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.
|
`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`, the credential prompts in `CredentialCollector`, the `.env` in `EnvFileWriter`, 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
|
[OC-001]: ./oc.md
|
||||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
[DCD-001]: ./dcd.md
|
||||||
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
|
[d773fa9]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/d773fa91df5a54090254e12e074880fb6526a9ff
|
||||||
|
|||||||
+10
-8
@@ -9,8 +9,8 @@
|
|||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-05 | Rejected | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
| 2026-10-06 | Accepted | Jens Tirsvad Nielsen | S02 | writeEnvFile parameter and the credentials that .env does not provide | [ded26a6] |
|
||||||
| 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] |
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | The license is not a parameter: it comes from config.env or follows the GitHub choice | [d773fa9] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ actor Maintainer as A
|
|||||||
participant ":System" as S
|
participant ":System" as S
|
||||||
A -> S : startProjectCreation()
|
A -> S : startProjectCreation()
|
||||||
S --> A : prompts for project details
|
S --> A : prompts for project details
|
||||||
A -> S : provideProjectDetails(name, description, visibility, giteaOwner, githubOwner, directory, enablePlanGate)
|
A -> S : provideProjectDetails(name, description, visibility, giteaOwner, githubOwner, directory, enablePlanGate, writeEnvFile)
|
||||||
S --> A : checks passed
|
S --> A : checks passed
|
||||||
S --> A : creation summary
|
S --> A : creation summary
|
||||||
@enduml
|
@enduml
|
||||||
@@ -36,19 +36,21 @@ S --> A : creation summary
|
|||||||
|
|
||||||
| Step | Message | Parameters | Return | Use case step |
|
| Step | Message | Parameters | Return | Use case step |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| 1 | startProjectCreation | none | prompts for project details (after configuration and tool checks) | 1, 2 |
|
| 1 | startProjectCreation | none | prompts for project details (after configuration and tool checks; a missing credential is asked first) | 1, 2 |
|
||||||
| 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 |
|
| 2 | provideProjectDetails | name, description, visibility, giteaOwner, githubOwner (optional; given means GitHub is chosen; omitted means no GitHub), directory, enablePlanGate (each of these may come from `config.env` instead of the Maintainer; the message is unchanged), writeEnvFile (whether to create the project's `.env`), and the credentials that `.env` does not provide (GitHub ones only when GitHub is chosen) | checks passed, then a creation summary | 3 to 10 |
|
||||||
|
|
||||||
|
The license that applies is not a parameter: it is read from `config.env` (`PROJECT_LICENSE`) or, when none is set, follows the GitHub choice.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Lifecycle Notes
|
## Lifecycle Notes
|
||||||
|
|
||||||
The system is one script run. It starts with the first operation and ends after the summary; nothing persists between runs.
|
The system is one script run. It starts with the first operation and ends after the summary; nothing persists between runs except the `.env` the Maintainer agreed to in the new project.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[UC-001]: ./uc.md
|
[UC-001]: ./uc.md
|
||||||
[DM-001]: ./dm.md
|
[DM-001]: ./dm.md
|
||||||
[OC-001]: ./oc.md
|
[OC-001]: ./oc.md
|
||||||
[424f14f]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/424f14f4f5577bb47fea41c8f3a655dca953e6d8
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
[d773fa9]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/d773fa91df5a54090254e12e074880fb6526a9ff
|
||||||
|
|||||||
+30
-15
@@ -9,8 +9,8 @@
|
|||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-05 | Deprecated | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
| 2026-10-06 | Accepted | Jens Tirsvad Nielsen | S02 | Credentials not in .env are asked (step 2, extension 2b); the project .env is created with consent (step 9, extensions 9c, 9d) | [ded26a6] |
|
||||||
| 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] |
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | The license comes from PROJECT_LICENSE (step 6, extension 4c); AGPL-3.0 is only the default when GitHub is chosen | [d773fa9] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,35 +26,43 @@
|
|||||||
- S02 — credentials are never exposed and nothing is overwritten silently
|
- S02 — credentials are never exposed and nothing is overwritten silently
|
||||||
- S03 — the published procedure is documented and reusable
|
- S03 — the published procedure is documented and reusable
|
||||||
- **Preconditions:**
|
- **Preconditions:**
|
||||||
- `config.env` and `.env` exist and are valid.
|
- `config.env` exists and is valid. `.env` may be missing or hold only some credentials; a credential it does not provide is asked.
|
||||||
|
- `config.env` may preset any of the project details of step 3, and may set the project license (`PROJECT_LICENSE`).
|
||||||
- `git` and `curl` are installed.
|
- `git` and `curl` are installed.
|
||||||
- The Maintainer has a Gitea token, a GitHub PAT (only when GitHub is chosen) and SSH access to Gitea on port 10022.
|
- The Maintainer has a Gitea token, a GitHub PAT and a GitHub account name (only when GitHub is chosen) and SSH access to Gitea on port 10022.
|
||||||
- **Postconditions (success guarantee):**
|
- **Postconditions (success guarantee):**
|
||||||
- A repository exists on Gitea under the chosen owner. It is empty, or, when the Maintainer chose GitHub, it holds the AGPL license file.
|
- A repository exists on Gitea under the chosen owner. It is empty, or it holds the license file that applies: the license set in `config.env`, or AGPL-3.0 when the Maintainer chose GitHub and set none.
|
||||||
- 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.
|
- 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 license file, if any, 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.
|
- 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.
|
||||||
|
- When the Maintainer agreed, the local project has a `.env` that holds only the credentials the project needs, is readable by its owner only and is ignored by git.
|
||||||
- The Maintainer has a summary of what was created.
|
- The Maintainer has a summary of what was created.
|
||||||
|
|
||||||
### Main Success Scenario
|
### Main Success Scenario
|
||||||
|
|
||||||
1. The Maintainer starts the project creation.
|
1. The Maintainer starts the project creation.
|
||||||
2. The system loads and validates the configuration and credentials and checks that the required tools exist.
|
2. The system loads and validates the configuration and credentials and checks that the required tools exist. A credential that `.env` does not provide is asked, without echo; the GitHub credentials are asked once GitHub is chosen.
|
||||||
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.
|
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. A detail that is set in `config.env` is not asked.
|
||||||
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.
|
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, that Gitea offers the license that applies (if any), and whether SSH to Gitea works.
|
||||||
5. Optional: if the Maintainer chose GitHub, the system creates the empty GitHub repository.
|
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.
|
6. The system creates the Gitea repository. If a license applies, the repository is created with its license file and so is not empty; otherwise it is empty. The license that applies is the one set in `config.env` (`PROJECT_LICENSE`; `none` means no license); when none is set it is AGPL-3.0 if the Maintainer chose GitHub, and none otherwise. The license is never asked.
|
||||||
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.
|
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.
|
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.
|
9. The system adds the framework submodule, installs its skills and hooks (and the plan gate if chosen) and copies the templates. If the Maintainer agrees, it also creates the project's own `.env` with the credentials the project needs.
|
||||||
10. The system reports a summary of what was created.
|
10. The system reports a summary of what was created.
|
||||||
|
|
||||||
### Extensions (Alternative / Exception Flows)
|
### Extensions (Alternative / Exception Flows)
|
||||||
|
|
||||||
- 2a. A required tool is missing, or a configuration value is missing or malformed:
|
- 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.
|
1. The system stops before any change and names the problem without showing a credential.
|
||||||
|
- 2b. A credential is not provided in `.env`:
|
||||||
|
1. The system asks for it without showing what is typed. An invalid value is refused and asked again; when input ends the system stops before any change and names the key.
|
||||||
|
- 3a. A project detail is set in `config.env`:
|
||||||
|
1. The system uses it and does not ask for it; the summary says it came from the configuration.
|
||||||
|
- 3b. A configured project detail is invalid:
|
||||||
|
1. The system stops before any request and names the key; it does not ask for the value instead.
|
||||||
- 4a. A token is invalid, an owner does not accept the repository, or the name is taken:
|
- 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. The GitHub token is only checked when GitHub was chosen.
|
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:
|
- 4c. A license applies and the Gitea server does not offer it:
|
||||||
1. The system stops before creating anything and names the missing license.
|
1. The system stops before creating anything and names the missing license.
|
||||||
- 4b. SSH to Gitea does not work:
|
- 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.
|
1. The system uses HTTPS for `origin` and warns that the framework submodule step will fail until SSH is configured.
|
||||||
@@ -64,15 +72,22 @@
|
|||||||
1. The system asks the Maintainer before replacing it; on no, it skips that item and reports it.
|
1. The system asks the Maintainer before replacing it; on no, it skips that item and reports it.
|
||||||
- 9b. A different git hooks setup is already configured in the project:
|
- 9b. A different git hooks setup is already configured in the project:
|
||||||
1. The system asks before replacing it.
|
1. The system asks before replacing it.
|
||||||
|
- 9c. The Maintainer declines creating the project's `.env`:
|
||||||
|
1. The system creates none and says so in the summary.
|
||||||
|
- 9d. A `.env` already exists in the project:
|
||||||
|
1. The system asks before replacing it; on no, it keeps it and reports it.
|
||||||
|
|
||||||
### Special Requirements / Business Rules
|
### Special Requirements / Business Rules
|
||||||
|
|
||||||
| Step | Rule |
|
| Step | Rule |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 2, 4 | A token never appears in output, logs, command lines, remote URLs or temporary files left behind |
|
| 2, 4 | A token never appears in output, logs, command lines, remote URLs or temporary files left behind |
|
||||||
|
| 2 | A credential that is asked is read without echo, validated like one read from `.env`, and held in memory for the run |
|
||||||
|
| 9 | The project's `.env` is the only place a token is written. It is created only after a yes (default no), holds only the keys the project needs (`GITEA_TOKEN`; `GITHUB_PAT` and `GITHUB_USER` when GitHub was chosen), is readable by its owner only, is excluded from git without changing a tracked file, and is never replaced without a yes |
|
||||||
| 3 | The GitHub owner and the Gitea owner are chosen separately; `GITHUB_USER` is only the authenticating account |
|
| 3 | The GitHub owner and the Gitea owner are chosen separately; `GITHUB_USER` is only the authenticating account |
|
||||||
|
| 3 | A project detail set in `config.env` (the key is present, even if empty where an empty value is allowed) is not asked; only the confirmations stay interactive |
|
||||||
| 3, 5, 7 | GitHub is optional; without it no GitHub repository, mirror or `github` remote is created and the GitHub credentials are not required |
|
| 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 |
|
| 6 | The license that applies is added to the Gitea repository when it is created, so that repository is not empty: `PROJECT_LICENSE` if set (a Gitea license key such as `MIT`, or `none`), otherwise AGPL-3.0 when GitHub is chosen, otherwise none. It is independent of the GitHub choice when set, and it is never asked |
|
||||||
| 7 | The mirror direction is Gitea to GitHub; the GitHub repository stays empty and receives its content from the mirror |
|
| 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 | `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 |
|
| 8, 9 | Nothing is overwritten or deleted without consent, and no commit is made |
|
||||||
@@ -87,5 +102,5 @@
|
|||||||
[US-001]: ../user-stories.md
|
[US-001]: ../user-stories.md
|
||||||
[SA-001]: ../stakeholder-analysis.md
|
[SA-001]: ../stakeholder-analysis.md
|
||||||
[DM-001]: ./dm.md
|
[DM-001]: ./dm.md
|
||||||
[424f14f]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/424f14f4f5577bb47fea41c8f3a655dca953e6d8
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
[d773fa9]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/d773fa91df5a54090254e12e074880fb6526a9ff
|
||||||
|
|||||||
+58
-8
@@ -4,13 +4,13 @@
|
|||||||
| Key | Value |
|
| Key | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| ID | US-001 |
|
| ID | US-001 |
|
||||||
| CrossReference | [BC-001], [UCD-001], [MIL-001], [MIL-002], [MIL-003] |
|
| CrossReference | [BC-001], [UCD-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006] |
|
||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-05 | Deprecated | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
| 2026-10-06 | Accepted | Jens Tirsvad Nielsen | S02 | Added US-001.05: credentials asked when missing and kept in the project .env | [ded26a6] |
|
||||||
| 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] |
|
| 2026-10-06 | Proposed | Jens Tirsvad Nielsen | S02 | Added US-001.06: the license set in config.env; US-001.02 names the license that applies | [d773fa9] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
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).
|
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.
|
The epic is split into six stories, one per milestone. Each story fits one two-week phase and can be shown working at the end of it.
|
||||||
|
|
||||||
## Story List
|
## Story List
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ The epic is split into three stories, one per milestone. Each story fits one two
|
|||||||
|
|
||||||
**Acceptance Criteria**
|
**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 valid tokens and owners, when the script runs, then a Gitea repository exists under the chosen owner: empty, or holding the license that applies (the one set in `PROJECT_LICENSE`, or AGPL-3.0 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 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.
|
- Given a step fails, when the script stops, then it reports what was created and how to continue.
|
||||||
|
|
||||||
@@ -64,9 +64,56 @@ The epic is split into three stories, one per milestone. Each story fits one two
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [UC-001] steps 8 to 10, [MIL-003] | fits one phase | Independent: needs the repositories of US-001.02 |
|
| [UC-001] steps 8 to 10, [MIL-003] | fits one phase | Independent: needs the repositories of US-001.02 |
|
||||||
|
|
||||||
|
### US-001.04 — Create a new project: preset the details
|
||||||
|
|
||||||
|
**As a** Maintainer, **I want** to set project details in `config.env`, **so that** the script does not ask for the same answers every time I create a project.
|
||||||
|
|
||||||
|
**Acceptance Criteria**
|
||||||
|
|
||||||
|
- Given a detail is set in `config.env`, when the script collects the details, then it does not ask for it, and the summary shows the value as coming from the configuration.
|
||||||
|
- Given a detail is not set in `config.env`, when the script collects the details, then it asks for it as before.
|
||||||
|
- Given a configured value is invalid, when the script starts, then it stops before any request to a host and names the key; it does not ask for the value instead.
|
||||||
|
- Given every detail is set, when the script runs, then the only questions left are the confirmations: create now, reuse of an existing repository, directory, hooks path or file.
|
||||||
|
|
||||||
|
| Traces to | Size | INVEST exceptions |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [UC-001] step 3, [MIL-004] | fits one phase | Independent: needs the prompts of US-001.01 |
|
||||||
|
|
||||||
|
### US-001.05 — Create a new project: ask for the credentials and keep them in the project
|
||||||
|
|
||||||
|
**As a** Maintainer, **I want** the script to ask for a credential that `.env` does not provide and to create a `.env` file in the new project, **so that** I can start without a prepared `.env` and the new project has the credentials its tools need.
|
||||||
|
|
||||||
|
**Acceptance Criteria**
|
||||||
|
|
||||||
|
- Given `GITEA_TOKEN` is not provided in `.env`, when the script starts, then it asks for it without showing what is typed and does not stop with an error; the same holds for `GITHUB_PAT` and `GITHUB_USER` when GitHub is chosen.
|
||||||
|
- Given an entered credential is not valid, when the script checks it, then it asks again and never shows the value.
|
||||||
|
- Given the project exists, when the Maintainer agrees, then the new project has a `.env` that holds only the credentials the project needs, is readable by its owner only and is ignored by git.
|
||||||
|
- Given the Maintainer declines, or `.env` already exists in the project and the Maintainer declines replacing it, then no `.env` is written or replaced and the summary says so.
|
||||||
|
- Given any run, then no credential appears in output, remotes, tracked files or the summary.
|
||||||
|
|
||||||
|
| Traces to | Size | INVEST exceptions |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [UC-001] steps 2 and 9, [MIL-005] | fits one phase | Independent: needs the local project of US-001.03 |
|
||||||
|
|
||||||
|
### US-001.06 — Create a new project: choose the license in `config.env`
|
||||||
|
|
||||||
|
**As a** Maintainer, **I want** to set the project's license in `config.env`, **so that** a project is not forced to AGPL-3.0 by the GitHub choice and does not need a question for it.
|
||||||
|
|
||||||
|
**Acceptance Criteria**
|
||||||
|
|
||||||
|
- Given `PROJECT_LICENSE` is set to a license the Gitea server offers, when the script creates the Gitea repository, then it holds that license, with or without GitHub, and the license is not asked.
|
||||||
|
- Given `PROJECT_LICENSE=none`, then the repository has no license even when GitHub is chosen.
|
||||||
|
- Given `PROJECT_LICENSE` is absent, then the license is AGPL-3.0 when GitHub is chosen and none otherwise, as before.
|
||||||
|
- Given the value is empty or invalid, or the server does not offer it, when the script starts or checks the hosts, then it stops before anything is created and names the key or the license.
|
||||||
|
- Given GitHub is chosen, then the license reaches the GitHub repository through the mirror, as before.
|
||||||
|
|
||||||
|
| Traces to | Size | INVEST exceptions |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [UC-001] steps 3, 4 and 6, [MIL-006] | fits one phase | Independent: needs the configurable details of US-001.04 |
|
||||||
|
|
||||||
## INVEST Check
|
## INVEST Check
|
||||||
|
|
||||||
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.
|
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 to US-001.06.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,6 +123,9 @@ Valuable, Negotiable, Estimable, Small and Testable hold for each story. Indepen
|
|||||||
[MIL-001]: ./milestones/mil-001-foundation.md
|
[MIL-001]: ./milestones/mil-001-foundation.md
|
||||||
[MIL-002]: ./milestones/mil-002-repositories-and-mirror.md
|
[MIL-002]: ./milestones/mil-002-repositories-and-mirror.md
|
||||||
[MIL-003]: ./milestones/mil-003-scaffold-and-release.md
|
[MIL-003]: ./milestones/mil-003-scaffold-and-release.md
|
||||||
|
[MIL-004]: ./milestones/mil-004-configurable-details.md
|
||||||
|
[MIL-005]: ./milestones/mil-005-credentials.md
|
||||||
|
[MIL-006]: ./milestones/mil-006-project-license.md
|
||||||
[PP-001]: ./project-plan.md
|
[PP-001]: ./project-plan.md
|
||||||
[424f14f]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/424f14f4f5577bb47fea41c8f3a655dca953e6d8
|
[ded26a6]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/ded26a658c666bf29d84093cb352e3635e07719b
|
||||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
[d773fa9]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/d773fa91df5a54090254e12e074880fb6526a9ff
|
||||||
|
|||||||
+110
-635
@@ -4,45 +4,74 @@
|
|||||||
# Purpose
|
# Purpose
|
||||||
# RepoFoundry creates a Gitea repository, optionally an empty GitHub
|
# RepoFoundry creates a Gitea repository, optionally an empty GitHub
|
||||||
# repository with a Gitea -> GitHub push mirror, and a local project with
|
# repository with a Gitea -> GitHub push mirror, and a local project with
|
||||||
# the SQA-QC-Framework. This version (MIL-001) validates the configuration
|
# the SQA-QC-Framework. It validates the configuration and credentials,
|
||||||
# and credentials, checks the required tools and asks for the project
|
# asks for the project details (those set in config.env are not asked), checks both hosts with read-only requests
|
||||||
# details. It does NOT contact GitHub or Gitea and changes nothing on disk;
|
# (tokens, owners, names, license, SSH) and, with --apply, creates the
|
||||||
# it only prints a summary of what it collected.
|
# repositories and the mirror, then the local project: its directory, git
|
||||||
|
# repository, remotes (no credential in any address), the framework as a
|
||||||
|
# submodule, the framework's skills and git hooks (and the plan gate if
|
||||||
|
# chosen) and its templates. Choosing GitHub also applies the AGPL-3.0
|
||||||
|
# license to the Gitea repository. No commit is made in the new project.
|
||||||
|
#
|
||||||
|
# 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
|
# Usage
|
||||||
# create-project.sh [--config FILE] [--env FILE]
|
# create-project.sh [--apply] [--config FILE] [--env FILE]
|
||||||
# create-project.sh --help | --version
|
# create-project.sh --help | --version
|
||||||
#
|
#
|
||||||
# Options
|
# Options
|
||||||
|
# --apply create the repositories and the mirror (after a final yes)
|
||||||
# --config FILE service addresses (default: config.env in the project root)
|
# --config FILE service addresses (default: config.env in the project root)
|
||||||
# --env FILE credentials (default: .env in the project root)
|
# --env FILE credentials (default: .env in the project root)
|
||||||
# -h, --help show this help
|
# -h, --help show this help
|
||||||
# --version show the version
|
# --version show the version
|
||||||
#
|
#
|
||||||
# Files (parsed, never sourced)
|
# Files (parsed, never sourced)
|
||||||
# config.env GITHUB_API_URL, GITHUB_WEB_URL, GITEA_URL, GITEA_API_URL
|
# config.env GITHUB_API_URL, GITHUB_WEB_URL, GITEA_URL, GITEA_API_URL and
|
||||||
|
# the optional GITEA_SSH_PORT (default 10022), MIRROR_INTERVAL
|
||||||
|
# (default 10m0s) and FRAMEWORK_REPO (default
|
||||||
|
# TirSystem/SQA-QC-Framework, the submodule's OWNER/NAME)
|
||||||
# .env GITHUB_PAT, GITHUB_USER, GITEA_TOKEN
|
# .env GITHUB_PAT, GITHUB_USER, GITEA_TOKEN
|
||||||
#
|
#
|
||||||
# Environment
|
# Environment
|
||||||
# REPOFOUNDRY_NAME project name used in messages (default: RepoFoundry)
|
# REPOFOUNDRY_NAME project name used in messages (default: RepoFoundry)
|
||||||
# TMPDIR where the private temporary directory is created
|
# REPOFOUNDRY_SYNC_WAIT seconds to wait before reading the first mirror
|
||||||
|
# sync result (default: 3)
|
||||||
|
# TMPDIR where the private temporary directory is created
|
||||||
#
|
#
|
||||||
# Requires
|
# Requires
|
||||||
# bash 4.4 or later, git, curl, mktemp; jq is optional (used when present).
|
# bash 4.4 or later, git, curl, mktemp; jq and ssh are optional (jq is used
|
||||||
# Also the base tools sed, grep, head, tr, rm, rmdir and uname, and stat
|
# for JSON when present; ssh is used for the Gitea SSH test).
|
||||||
# (GNU "stat -c" or BSD "stat -f"; only used outside Windows).
|
# Also the base tools sed, grep, head, tr, sleep, find, cp, mkdir, chmod, env, rm,
|
||||||
|
# rmdir and uname, and
|
||||||
|
# stat (GNU "stat -c" or BSD "stat -f"; only used outside Windows).
|
||||||
#
|
#
|
||||||
# Implements
|
# Implements
|
||||||
# MIL-001 tasks 1 to 6 (issues #3 to #8), user story US-001.01 and UC-001
|
# MIL-001 tasks 1 to 6, MIL-002 tasks 1 to 5 and MIL-003 tasks 1 to 4
|
||||||
# steps 1 to 3; see docs/. Deviation from the request: its second
|
# (issues #3 to #13 and #15 to #18), user stories US-001.01 to US-001.03,
|
||||||
|
# UC-001 steps 1 to 10; see docs/. Deviation from the request: its second
|
||||||
# GITEA_URL key is named GITEA_API_URL.
|
# GITEA_URL key is named GITEA_API_URL.
|
||||||
#
|
#
|
||||||
# Tracing
|
# Tracing
|
||||||
# set -x is switched off while the script runs, because a trace would print
|
# set -x is switched off while the script runs, because a trace would print
|
||||||
# every secret the script handles.
|
# 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, git, localproject, framework, apply and cli. The files are loaded
|
||||||
|
# from this directory only.
|
||||||
|
#
|
||||||
# Exit codes
|
# Exit codes
|
||||||
# 0 success, 1 a failed check or bad input, 2 a usage error.
|
# 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
|
set -Eeuo pipefail
|
||||||
|
|
||||||
if [[ $- == *x* ]]; then
|
if [[ $- == *x* ]]; then
|
||||||
@@ -55,17 +84,9 @@ if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4)));
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
readonly PROJECT_NAME="${REPOFOUNDRY_NAME:-RepoFoundry}"
|
# Where this script lives; the library files and the project root are found
|
||||||
readonly VERSION="0.1.0"
|
# from here, never from the current directory.
|
||||||
readonly EXIT_FAILURE=1
|
readonly SCRIPT_FILE="${BASH_SOURCE[0]}"
|
||||||
readonly EXIT_USAGE=2
|
|
||||||
readonly MAX_VALUE_LENGTH=2048
|
|
||||||
readonly MAX_DESCRIPTION_LENGTH=350
|
|
||||||
readonly HTTP_TIMEOUT_SECONDS=30
|
|
||||||
# shellcheck disable=SC2034 # read through namerefs (parse_env_file)
|
|
||||||
readonly CONFIG_KEYS=(GITHUB_API_URL GITHUB_WEB_URL GITEA_URL GITEA_API_URL)
|
|
||||||
readonly CREDENTIAL_KEYS=(GITHUB_PAT GITHUB_USER GITEA_TOKEN)
|
|
||||||
|
|
||||||
case "${BASH_SOURCE[0]}" in
|
case "${BASH_SOURCE[0]}" in
|
||||||
*/*) script_path_dir="${BASH_SOURCE[0]%/*}" ;;
|
*/*) script_path_dir="${BASH_SOURCE[0]%/*}" ;;
|
||||||
*) script_path_dir="." ;;
|
*) script_path_dir="." ;;
|
||||||
@@ -78,622 +99,67 @@ unset script_path_dir
|
|||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
readonly PROJECT_ROOT
|
readonly PROJECT_ROOT
|
||||||
|
|
||||||
CONFIG_FILE="$PROJECT_ROOT/config.env"
|
# shellcheck source=lib/constants.sh
|
||||||
ENV_FILE="$PROJECT_ROOT/.env"
|
source "$SCRIPT_DIR/lib/constants.sh"
|
||||||
TMP_DIR=""
|
# shellcheck source=lib/output.sh
|
||||||
HAS_JQ=0
|
source "$SCRIPT_DIR/lib/output.sh"
|
||||||
HTTP_STATUS=0
|
# shellcheck source=lib/temp.sh
|
||||||
HTTP_BODY_FILE=""
|
source "$SCRIPT_DIR/lib/temp.sh"
|
||||||
HTTP_ERROR=""
|
# shellcheck source=lib/util.sh
|
||||||
REPLY=""
|
source "$SCRIPT_DIR/lib/util.sh"
|
||||||
SECRET_VALUES=()
|
# shellcheck source=lib/validate.sh
|
||||||
TEMP_FILES=()
|
source "$SCRIPT_DIR/lib/validate.sh"
|
||||||
declare -A CONFIG=()
|
# shellcheck source=lib/config.sh
|
||||||
declare -A CREDENTIALS=()
|
source "$SCRIPT_DIR/lib/config.sh"
|
||||||
declare -A PROJECT=()
|
# 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/git.sh
|
||||||
|
source "$SCRIPT_DIR/lib/git.sh"
|
||||||
|
# shellcheck source=lib/localproject.sh
|
||||||
|
source "$SCRIPT_DIR/lib/localproject.sh"
|
||||||
|
# shellcheck source=lib/framework.sh
|
||||||
|
source "$SCRIPT_DIR/lib/framework.sh"
|
||||||
|
# shellcheck source=lib/apply.sh
|
||||||
|
source "$SCRIPT_DIR/lib/apply.sh"
|
||||||
|
# shellcheck source=lib/cli.sh
|
||||||
|
source "$SCRIPT_DIR/lib/cli.sh"
|
||||||
|
|
||||||
# ---------------------------------------------------------------- output
|
# 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
|
||||||
# redact TEXT: print TEXT with every known secret value replaced.
|
# exit status of the run.
|
||||||
redact() {
|
finish() {
|
||||||
local text="$1" secret
|
local code=$?
|
||||||
for secret in "${SECRET_VALUES[@]}"; do
|
if ((IS_CREATION_STARTED)); then
|
||||||
if [[ -n $secret ]]; then
|
report_outcome "$code"
|
||||||
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
|
fi
|
||||||
printf 'error: %s\n' "$(redact "$*")" >&2
|
cleanup
|
||||||
exit "$code"
|
|
||||||
}
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
Usage: ${0##*/} [--config FILE] [--env FILE]
|
|
||||||
${0##*/} --help | --version
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
usage_error() {
|
|
||||||
printf 'error: %s\n' "$1" >&2
|
|
||||||
usage >&2
|
|
||||||
exit "$EXIT_USAGE"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------- temporary files
|
|
||||||
|
|
||||||
# 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------ small helpers
|
|
||||||
|
|
||||||
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:]]* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------- validators
|
|
||||||
|
|
||||||
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}$ ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
normalize_url() {
|
|
||||||
local url="$1"
|
|
||||||
while [[ $url == */ ]]; do
|
|
||||||
url="${url%/}"
|
|
||||||
done
|
|
||||||
printf '%s' "$url"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --------------------------------------------------- config file parsing
|
|
||||||
|
|
||||||
# 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"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------- configuration checks
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
# -------------------------------------------------------------- tool check
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- JSON
|
|
||||||
|
|
||||||
# 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
|
|
||||||
}
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- HTTP
|
|
||||||
|
|
||||||
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=$?
|
|
||||||
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=""
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------- prompts
|
|
||||||
|
|
||||||
# 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------- summary
|
|
||||||
|
|
||||||
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: nothing has been created yet."
|
|
||||||
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)"
|
|
||||||
say "Creating the repositories and the project comes in later phases."
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- main
|
|
||||||
|
|
||||||
parse_args() {
|
|
||||||
while (($# > 0)); do
|
|
||||||
case "$1" in
|
|
||||||
--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' "${BASH_SOURCE[0]}" | sed -e '$d' -e 's/^# \{0,1\}//'
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
--version)
|
|
||||||
say "$PROJECT_NAME $VERSION"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*) usage_error "unknown option: $1" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
trap 'on_error "$LINENO"' ERR
|
trap 'on_error "$LINENO"' ERR
|
||||||
trap cleanup EXIT
|
trap finish EXIT
|
||||||
is_valid_repo_name "$PROJECT_NAME" ||
|
is_valid_repo_name "$PROJECT_NAME" ||
|
||||||
die "REPOFOUNDRY_NAME is not a valid project name"
|
die "REPOFOUNDRY_NAME is not a valid project name"
|
||||||
parse_args "$@"
|
parse_args "$@"
|
||||||
@@ -704,7 +170,16 @@ main() {
|
|||||||
if ((PROJECT[has_github])); then
|
if ((PROJECT[has_github])); then
|
||||||
require_github_credentials
|
require_github_credentials
|
||||||
fi
|
fi
|
||||||
|
init_steps
|
||||||
print_summary
|
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
|
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||||
|
|||||||
@@ -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,67 @@
|
|||||||
|
# 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, confirm_framework_access, 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
|
||||||
|
create_local_project
|
||||||
|
add_framework
|
||||||
|
install_framework
|
||||||
|
copy_templates
|
||||||
|
}
|
||||||
|
|
||||||
|
# confirm_framework_access: the framework comes over SSH. Without SSH the
|
||||||
|
# Maintainer can still go on, without the framework steps, after a yes.
|
||||||
|
confirm_framework_access() {
|
||||||
|
if ((${STATE[is_ssh_ok]:-0})); then
|
||||||
|
STATE[skip_framework]=0
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
warn "SSH to Gitea ($(gitea_host) port ${CONFIG[GITEA_SSH_PORT]}) did not work, so the framework cannot be added: ${STATE[ssh_note]}"
|
||||||
|
prompt_yes_no "Create the repositories and the local project without the framework" n
|
||||||
|
if ! ((REPLY)); then
|
||||||
|
die "stopped: set up SSH access to Gitea (see the README) and run again"
|
||||||
|
fi
|
||||||
|
STATE[skip_framework]=1
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
confirm_local_directory
|
||||||
|
confirm_framework_access
|
||||||
|
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,206 @@
|
|||||||
|
# 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, check_preset, check_preset_choice, validate_project_presets, 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"
|
||||||
|
CONFIG[FRAMEWORK_REPO]="${CONFIG[FRAMEWORK_REPO]:-$DEFAULT_FRAMEWORK_REPO}"
|
||||||
|
is_valid_framework_repo "${CONFIG[FRAMEWORK_REPO]}" ||
|
||||||
|
die "FRAMEWORK_REPO in $CONFIG_FILE must look like OWNER/NAME"
|
||||||
|
validate_project_presets
|
||||||
|
}
|
||||||
|
|
||||||
|
# check_preset KEY VALIDATOR HINT: when KEY is set in config.env its value must
|
||||||
|
# pass VALIDATOR. A key that is present counts as set; only the description
|
||||||
|
# may be empty. The message names the key, never the value.
|
||||||
|
check_preset() {
|
||||||
|
local key="$1" validator="$2" hint="$3"
|
||||||
|
[[ -n ${CONFIG[$key]+set} ]] || return 0
|
||||||
|
if [[ -z ${CONFIG[$key]} && $key != PROJECT_DESCRIPTION ]]; then
|
||||||
|
die "$key in $CONFIG_FILE is empty; remove the line to be asked, or give a value ($hint)"
|
||||||
|
fi
|
||||||
|
"$validator" "${CONFIG[$key]}" ||
|
||||||
|
die "$key in $CONFIG_FILE is not valid: $hint"
|
||||||
|
}
|
||||||
|
|
||||||
|
# check_preset_choice KEY CHOICE...: like check_preset for a fixed list of
|
||||||
|
# words; the value is stored in lower case.
|
||||||
|
check_preset_choice() {
|
||||||
|
local key="$1"
|
||||||
|
shift
|
||||||
|
[[ -n ${CONFIG[$key]+set} ]] || return 0
|
||||||
|
[[ -n ${CONFIG[$key]} ]] ||
|
||||||
|
die "$key in $CONFIG_FILE is empty; remove the line to be asked, or give one of: $*"
|
||||||
|
CONFIG[$key]="${CONFIG[$key],,}"
|
||||||
|
in_list "${CONFIG[$key]}" "$@" ||
|
||||||
|
die "$key in $CONFIG_FILE must be one of: $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The optional project details that may be preset in config.env.
|
||||||
|
validate_project_presets() {
|
||||||
|
check_preset PROJECT_NAME is_valid_repo_name "$HINT_REPO_NAME"
|
||||||
|
check_preset PROJECT_DESCRIPTION is_valid_description "$HINT_DESCRIPTION"
|
||||||
|
check_preset_choice PROJECT_VISIBILITY private public
|
||||||
|
check_preset GITEA_OWNER is_valid_gitea_owner "$HINT_GITEA_OWNER"
|
||||||
|
check_preset_choice USE_GITHUB yes no
|
||||||
|
check_preset GITHUB_OWNER is_valid_github_owner "$HINT_GITHUB_OWNER"
|
||||||
|
check_preset PROJECT_DIRECTORY is_valid_directory "$HINT_DIRECTORY"
|
||||||
|
check_preset_choice ENABLE_PLAN_GATE yes no
|
||||||
|
}
|
||||||
|
|
||||||
|
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,59 @@
|
|||||||
|
# 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.3.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 DEFAULT_FRAMEWORK_REPO="TirSystem/SQA-QC-Framework"
|
||||||
|
readonly AGPL_LICENSE_KEY="AGPL-3.0"
|
||||||
|
readonly DEFAULT_BRANCH="main"
|
||||||
|
# What the prompts and the preset keys in config.env both tell the Maintainer
|
||||||
|
# when a value is refused.
|
||||||
|
readonly HINT_REPO_NAME="use letters, digits, '.', '_' or '-' (at most 100), not ending in .git"
|
||||||
|
readonly HINT_DESCRIPTION="at most $MAX_DESCRIPTION_LENGTH characters and no control characters"
|
||||||
|
readonly HINT_GITEA_OWNER="use letters, digits, '.', '_' or '-' (at most 39)"
|
||||||
|
readonly HINT_GITHUB_OWNER="use letters, digits or '-' (at most 39)"
|
||||||
|
readonly HINT_DIRECTORY="must not be empty, start with '-' or contain control characters"
|
||||||
|
readonly PLAN_STEPS=("GitHub repository" "Gitea repository" "Push mirror"
|
||||||
|
"Local project" "Framework" "Skills and hooks" "Templates")
|
||||||
|
# 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 FRAMEWORK_REPO
|
||||||
|
PROJECT_NAME PROJECT_DESCRIPTION PROJECT_VISIBILITY GITEA_OWNER USE_GITHUB
|
||||||
|
GITHUB_OWNER PROJECT_DIRECTORY ENABLE_PLAN_GATE)
|
||||||
|
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=()
|
||||||
|
# Project details that came from config.env instead of a prompt (PRESET[name]=1).
|
||||||
|
declare -A PRESET=()
|
||||||
|
# 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,153 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||||
|
# framework.sh - The SQA-QC-Framework in the new project: submodule, skills, hooks and templates.
|
||||||
|
#
|
||||||
|
# Part of create-project.sh: sourced by it, never run on its own.
|
||||||
|
#
|
||||||
|
# Provides: is_framework_skipped, add_framework, run_framework_script, install_skills, install_hooks, install_framework, copy_template, copy_templates
|
||||||
|
|
||||||
|
# is_framework_skipped: succeed when the framework steps are left out because
|
||||||
|
# SSH to Gitea is not available (the Maintainer agreed to that).
|
||||||
|
is_framework_skipped() {
|
||||||
|
[[ ${STATE[skip_framework]:-0} == 1 ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# add_framework: git submodule add of the framework as "framework". SSH is
|
||||||
|
# needed for it; a failure says how to test the access.
|
||||||
|
add_framework() {
|
||||||
|
local label="Framework" dir="${PROJECT[directory]}" url err existing
|
||||||
|
url="$(framework_url)"
|
||||||
|
if is_framework_skipped; then
|
||||||
|
finish_step "$label" "skipped" "(no SSH access to Gitea)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
begin_step "$label"
|
||||||
|
if [[ -e $dir/framework ]]; then
|
||||||
|
existing="$(git_project "$dir" config -f .gitmodules --get submodule.framework.url || true)"
|
||||||
|
if [[ $existing != "$url" ]]; then
|
||||||
|
die "'framework' already exists in $dir and is not the framework submodule ($url)"
|
||||||
|
fi
|
||||||
|
finish_step "$label" "reused" "$url (already a submodule)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
make_temp_file
|
||||||
|
err="$REPLY"
|
||||||
|
if ! git_project "$dir" submodule add -q "$url" framework 2>"$err"; then
|
||||||
|
die "git could not add the framework from $url. Check the SSH access first: ssh -p ${CONFIG[GITEA_SSH_PORT]} -T git@$(gitea_host). Git said: $(head -n 2 "$err" | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
finish_step "$label" "created" "$url"
|
||||||
|
}
|
||||||
|
|
||||||
|
# run_framework_script DIR SCRIPT [ARG...]: run one of the framework's own
|
||||||
|
# scripts inside the project. PROJECT_ROOT is set explicitly so that a
|
||||||
|
# PROJECT_ROOT in the caller's environment cannot point it elsewhere.
|
||||||
|
run_framework_script() {
|
||||||
|
local dir="$1" script="$2" out abs
|
||||||
|
shift 2
|
||||||
|
abs="$(cd "$dir" && pwd)"
|
||||||
|
make_temp_file
|
||||||
|
out="$REPLY"
|
||||||
|
if ! (cd "$abs" && env PROJECT_ROOT="$abs" bash "framework/scripts/$script" "$@" </dev/null) >"$out" 2>&1; then
|
||||||
|
die "the framework script $script failed: $(tail -n 3 "$out" | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# install_skills DIR: install the framework's skills once. The installer is
|
||||||
|
# safe to repeat but copies everything again, so a project that already has
|
||||||
|
# them is left alone.
|
||||||
|
install_skills() {
|
||||||
|
local dir="$1"
|
||||||
|
if [[ -f $dir/.agents/skills/.framework-skills && -f $dir/.claude/skills/.framework-skills ]]; then
|
||||||
|
STATE[skills_note]="skills already installed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
run_framework_script "$dir" install-skills.sh
|
||||||
|
STATE[skills_note]="skills installed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# install_hooks DIR: point core.hooksPath at the framework's hooks, and turn
|
||||||
|
# on the plan gate if chosen. A different hooks path that is already set is
|
||||||
|
# only replaced after a yes.
|
||||||
|
install_hooks() {
|
||||||
|
local dir="$1" current global gate=() gate_enabled
|
||||||
|
if ((PROJECT[is_plan_gate_enabled])); then
|
||||||
|
gate=(--enable-plan-gate)
|
||||||
|
fi
|
||||||
|
# Both settings are normally unset; git config exits 1 then.
|
||||||
|
current="$(git_project "$dir" config --local --get core.hooksPath || true)"
|
||||||
|
global="$(git_project "$dir" config --global --get core.hooksPath || true)"
|
||||||
|
gate_enabled="$(git_project "$dir" config --local --get planGate.enabled || true)"
|
||||||
|
if [[ -z $current && -n $global ]]; then
|
||||||
|
warn "your global core.hooksPath is '$global'; this project sets its own, which takes precedence here"
|
||||||
|
fi
|
||||||
|
if [[ -z $current ]]; then
|
||||||
|
run_framework_script "$dir" install-git-hooks.sh "${gate[@]}"
|
||||||
|
STATE[hooks_note]="hooks installed"
|
||||||
|
elif [[ $current == framework/githooks ]]; then
|
||||||
|
STATE[hooks_note]="hooks already installed"
|
||||||
|
if ((PROJECT[is_plan_gate_enabled])) && [[ $gate_enabled != true ]]; then
|
||||||
|
run_framework_script "$dir" install-git-hooks.sh "${gate[@]}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
prompt_yes_no "core.hooksPath is already '$current'. Replace it with framework/githooks" n
|
||||||
|
if ((REPLY)); then
|
||||||
|
run_framework_script "$dir" install-git-hooks.sh "${gate[@]}"
|
||||||
|
STATE[hooks_note]="hooks installed (replaced '$current')"
|
||||||
|
else
|
||||||
|
STATE[hooks_note]="kept the existing hooks path '$current'"
|
||||||
|
if ((PROJECT[is_plan_gate_enabled])); then
|
||||||
|
warn "the plan gate is not enabled because the framework hooks were not installed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# install_framework: skills, then hooks (and the plan gate). Each is done once.
|
||||||
|
install_framework() {
|
||||||
|
local label="Skills and hooks" dir="${PROJECT[directory]}" gate_state="plan gate off"
|
||||||
|
if is_framework_skipped; then
|
||||||
|
finish_step "$label" "skipped" "(no SSH access to Gitea)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
begin_step "$label"
|
||||||
|
install_skills "$dir"
|
||||||
|
install_hooks "$dir"
|
||||||
|
if [[ $(git_project "$dir" config --local --get planGate.enabled || true) == true ]]; then
|
||||||
|
gate_state="plan gate on"
|
||||||
|
fi
|
||||||
|
finish_step "$label" "created" "(${STATE[skills_note]}; ${STATE[hooks_note]}; $gate_state)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# copy_template DIR SOURCE TARGET: copy a framework template. An existing
|
||||||
|
# target is only replaced after a yes. The result goes to STATE[template_note].
|
||||||
|
copy_template() {
|
||||||
|
local dir="$1" source="$2" target="$3"
|
||||||
|
if [[ ! -f $dir/$source ]]; then
|
||||||
|
die "the framework has no $source; is the submodule complete?"
|
||||||
|
fi
|
||||||
|
if [[ -e $dir/$target ]]; then
|
||||||
|
prompt_yes_no "$target already exists. Replace it with the framework template" n
|
||||||
|
if ! ((REPLY)); then
|
||||||
|
STATE[template_note]="kept existing $target"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
cp -- "$dir/$source" "$dir/$target"
|
||||||
|
STATE[template_note]="copied $target"
|
||||||
|
}
|
||||||
|
|
||||||
|
# copy_templates: AGENTS.md and docs/artifact-registry.md from the framework.
|
||||||
|
copy_templates() {
|
||||||
|
local label="Templates" dir="${PROJECT[directory]}" notes=""
|
||||||
|
if is_framework_skipped; then
|
||||||
|
finish_step "$label" "skipped" "(no SSH access to Gitea)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
begin_step "$label"
|
||||||
|
mkdir -p -- "$dir/docs"
|
||||||
|
copy_template "$dir" framework/templates/AGENTS-template.md AGENTS.md
|
||||||
|
notes="${STATE[template_note]}"
|
||||||
|
copy_template "$dir" framework/templates/artifact-registry-template.md docs/artifact-registry.md
|
||||||
|
notes="$notes; ${STATE[template_note]}"
|
||||||
|
finish_step "$label" "created" "($notes)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||||
|
# git.sh - Running git for the new project: no prompts, no token on a command line.
|
||||||
|
#
|
||||||
|
# Part of create-project.sh: sourced by it, never run on its own.
|
||||||
|
#
|
||||||
|
# Provides: git_project, fetch_origin
|
||||||
|
|
||||||
|
# git_project DIR ARGS...: run git in DIR. Prompts are switched off and stdin
|
||||||
|
# is closed (git must not eat the answers meant for later prompts), so a
|
||||||
|
# missing credential or SSH key fails at once instead of waiting for input.
|
||||||
|
git_project() {
|
||||||
|
local dir="$1"
|
||||||
|
shift
|
||||||
|
GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh} -o BatchMode=yes" \
|
||||||
|
git -C "$dir" "$@" </dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# fetch_origin DIR: fetch the Gitea repository into the project. Over SSH the
|
||||||
|
# user's key is used. Over HTTPS the token reaches git through a private
|
||||||
|
# GIT_ASKPASS helper and the environment of this one command: it is never part
|
||||||
|
# of a URL, of the remote configuration or of a command line.
|
||||||
|
fetch_origin() {
|
||||||
|
local dir="$1" url askpass
|
||||||
|
url="$(git_project "$dir" config --get remote.origin.url)"
|
||||||
|
if [[ $url != https://* ]]; then
|
||||||
|
git_project "$dir" fetch -q origin
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
make_temp_file
|
||||||
|
askpass="$REPLY"
|
||||||
|
# shellcheck disable=SC2016 # the helper is written out literally: it expands $1 and its environment itself
|
||||||
|
{
|
||||||
|
printf '%s\n' '#!/usr/bin/env bash'
|
||||||
|
printf '%s\n' 'case "$1" in'
|
||||||
|
printf '%s\n' ' *sername*) printf "%s\n" "$REPOFOUNDRY_ASKPASS_USER" ;;'
|
||||||
|
printf '%s\n' ' *) printf "%s\n" "$REPOFOUNDRY_ASKPASS_TOKEN" ;;'
|
||||||
|
printf '%s\n' 'esac'
|
||||||
|
} >"$askpass"
|
||||||
|
chmod 700 "$askpass"
|
||||||
|
# Not "cmd; rm": a failed fetch must still be reported to the caller.
|
||||||
|
if ! GIT_ASKPASS="$askpass" REPOFOUNDRY_ASKPASS_USER="${STATE[gitea_login]}" \
|
||||||
|
REPOFOUNDRY_ASKPASS_TOKEN="${CREDENTIALS[GITEA_TOKEN]}" \
|
||||||
|
git_project "$dir" fetch -q origin; then
|
||||||
|
rm -f -- "$askpass"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f -- "$askpass"
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 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, gitea_host, origin_protocol, origin_url, github_remote_url, framework_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
|
||||||
|
}
|
||||||
|
|
||||||
|
# gitea_host: the host name of the Gitea server, from GITEA_URL.
|
||||||
|
gitea_host() {
|
||||||
|
local host="${CONFIG[GITEA_URL]#https://}"
|
||||||
|
host="${host%%/*}"
|
||||||
|
printf '%s' "${host%%:*}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# origin_protocol: SSH when the SSH test passed, otherwise HTTPS.
|
||||||
|
origin_protocol() {
|
||||||
|
if ((${STATE[is_ssh_ok]:-0})); then
|
||||||
|
printf 'SSH'
|
||||||
|
else
|
||||||
|
printf 'HTTPS'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# origin_url: the address of the Gitea repository for the origin remote. It
|
||||||
|
# never holds a credential: "git@" is the SSH user name, not a secret.
|
||||||
|
origin_url() {
|
||||||
|
if [[ $(origin_protocol) == SSH ]]; then
|
||||||
|
printf 'ssh://git@%s:%s/%s/%s.git' "$(gitea_host)" "${CONFIG[GITEA_SSH_PORT]}" \
|
||||||
|
"${PROJECT[gitea_owner]}" "${PROJECT[name]}"
|
||||||
|
else
|
||||||
|
printf '%s/%s/%s.git' "${CONFIG[GITEA_URL]}" "${PROJECT[gitea_owner]}" "${PROJECT[name]}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
github_remote_url() {
|
||||||
|
printf '%s/%s/%s.git' "${CONFIG[GITHUB_WEB_URL]}" "${PROJECT[github_owner]}" "${PROJECT[name]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# framework_url: where the framework submodule comes from (always SSH).
|
||||||
|
framework_url() {
|
||||||
|
printf 'ssh://git@%s:%s/%s.git' "$(gitea_host)" "${CONFIG[GITEA_SSH_PORT]}" \
|
||||||
|
"${CONFIG[FRAMEWORK_REPO]}"
|
||||||
|
}
|
||||||
@@ -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[@]}" </dev/null 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,90 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||||
|
# localproject.sh - The local project: its directory, its git repository and its remotes.
|
||||||
|
#
|
||||||
|
# Part of create-project.sh: sourced by it, never run on its own.
|
||||||
|
#
|
||||||
|
# Provides: inspect_local_directory, confirm_local_directory, ensure_remote, checkout_gitea_history, create_local_project
|
||||||
|
|
||||||
|
# inspect_local_directory: record in STATE[local_dir] whether the project
|
||||||
|
# directory is missing, empty or not_empty. It creates nothing.
|
||||||
|
inspect_local_directory() {
|
||||||
|
local dir="${PROJECT[directory]}"
|
||||||
|
if [[ ! -e $dir ]]; then
|
||||||
|
STATE[local_dir]="missing"
|
||||||
|
elif [[ ! -d $dir ]]; then
|
||||||
|
die "$dir already exists and is not a directory"
|
||||||
|
elif [[ -z "$(find "$dir" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
|
||||||
|
STATE[local_dir]="empty"
|
||||||
|
else
|
||||||
|
STATE[local_dir]="not_empty"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# confirm_local_directory: an existing directory is only used after a yes.
|
||||||
|
confirm_local_directory() {
|
||||||
|
local dir="${PROJECT[directory]}" what="is empty"
|
||||||
|
if [[ ${STATE[local_dir]} == missing ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ ${STATE[local_dir]} == not_empty ]]; then
|
||||||
|
what="already has files"
|
||||||
|
fi
|
||||||
|
prompt_yes_no "The directory $dir already exists and $what. Use it" n
|
||||||
|
if ! ((REPLY)); then
|
||||||
|
die "stopped: choose another directory or remove this one"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ensure_remote DIR NAME URL: add the remote, or accept one that already has
|
||||||
|
# exactly this address. A different address is never overwritten.
|
||||||
|
ensure_remote() {
|
||||||
|
local dir="$1" name="$2" url="$3" existing
|
||||||
|
# git config exits 1 when the remote is not set; that is the normal case.
|
||||||
|
existing="$(git_project "$dir" config --get "remote.$name.url" || true)"
|
||||||
|
if [[ -z $existing ]]; then
|
||||||
|
git_project "$dir" remote add "$name" "$url"
|
||||||
|
elif [[ $existing != "$url" ]]; then
|
||||||
|
die "the remote '$name' in $dir already points to $existing; remove it or choose another directory"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# checkout_gitea_history DIR: when the Gitea repository holds the license
|
||||||
|
# commit, fetch it and start the local branch from it, so the local history
|
||||||
|
# begins with that commit. Existing files are never overwritten: git refuses.
|
||||||
|
checkout_gitea_history() {
|
||||||
|
local dir="$1" err
|
||||||
|
if ! fetch_origin "$dir" 2>/dev/null; then
|
||||||
|
die "could not fetch the Gitea repository from $(origin_url); check your SSH key (or, over HTTPS, the token) and run the same command again"
|
||||||
|
fi
|
||||||
|
if ! git_project "$dir" rev-parse --verify -q refs/remotes/origin/main >/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if git_project "$dir" rev-parse --verify -q HEAD >/dev/null 2>&1; then
|
||||||
|
warn "$dir already has history: the Gitea content was fetched but not checked out"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
make_temp_file
|
||||||
|
err="$REPLY"
|
||||||
|
if ! git_project "$dir" checkout -q -b main --track origin/main 2>"$err"; then
|
||||||
|
die "git would overwrite files in $dir with the Gitea content; move them away and run again. Git said: $(head -n 2 "$err" | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# create_local_project: the directory, the git repository on main, the
|
||||||
|
# remotes and, when GitHub is chosen, the license history. No commit is made.
|
||||||
|
create_local_project() {
|
||||||
|
local label="Local project" dir="${PROJECT[directory]}"
|
||||||
|
begin_step "$label"
|
||||||
|
mkdir -p -- "$dir"
|
||||||
|
if [[ ! -e $dir/.git ]]; then
|
||||||
|
git_project "$dir" init -q
|
||||||
|
git_project "$dir" symbolic-ref HEAD "refs/heads/$DEFAULT_BRANCH"
|
||||||
|
fi
|
||||||
|
ensure_remote "$dir" origin "$(origin_url)"
|
||||||
|
if ((PROJECT[has_github])); then
|
||||||
|
ensure_remote "$dir" github "$(github_remote_url)"
|
||||||
|
checkout_gitea_history "$dir"
|
||||||
|
fi
|
||||||
|
finish_step "$label" "created" "$dir (origin over $(origin_protocol))"
|
||||||
|
}
|
||||||
@@ -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,57 @@
|
|||||||
|
# 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: local_plan_note, print_plan
|
||||||
|
|
||||||
|
# local_plan_note: what will happen to the project directory.
|
||||||
|
local_plan_note() {
|
||||||
|
local dir="${PROJECT[directory]}"
|
||||||
|
case "${STATE[local_dir]}" in
|
||||||
|
missing) printf 'create %s (new directory), git on %s, no commit' "$dir" "$DEFAULT_BRANCH" ;;
|
||||||
|
empty) printf 'use the existing empty directory %s (you will be asked)' "$dir" ;;
|
||||||
|
*) printf 'use the existing directory %s, which has files (you will be asked)' "$dir" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
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 project" "$(local_plan_note)")"
|
||||||
|
say "$(printf ' %-18s: %s' "Local origin" "will use $origin_note")"
|
||||||
|
if ((STATE[is_ssh_ok])); then
|
||||||
|
say "$(printf ' %-18s: %s' "Framework" "add $(framework_url) as a submodule")"
|
||||||
|
say "$(printf ' %-18s: %s' "Skills and hooks" "install once; plan gate $(yes_no "${PROJECT[is_plan_gate_enabled]}")")"
|
||||||
|
say "$(printf ' %-18s: %s' "Templates" "AGENTS.md and docs/artifact-registry.md (you are asked before a file is replaced)")"
|
||||||
|
else
|
||||||
|
say "$(printf ' %-18s: %s' "Framework" "NOT possible without SSH to Gitea; you will be asked whether to go on without it")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# 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, initial_only (just the LICENSE and the
|
||||||
|
# README.md Gitea adds) 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
|
||||||
|
# Gitea adds a README.md of its own next to the LICENSE when it creates a
|
||||||
|
# repository with a license (seen on a real server), so both count as the
|
||||||
|
# content this script creates.
|
||||||
|
names="$(json_values "$HTTP_BODY_FILE" name | sort | tr '\n' ' ')"
|
||||||
|
case "${names% }" in
|
||||||
|
"") STATE[${host}_repo]="empty" ;;
|
||||||
|
"LICENSE" | "LICENSE README.md") STATE[${host}_repo]="initial_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="$(gitea_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" </dev/null 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 ;;
|
||||||
|
initial_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
|
||||||
|
inspect_local_directory
|
||||||
|
say "All checks passed."
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# 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: preset_detail, collect_project_details, collect_github_details, source_note, yes_no, credential_state, print_summary
|
||||||
|
|
||||||
|
# preset_detail KEY NAME: a detail set in config.env is used and not asked;
|
||||||
|
# the value is returned in REPLY and marked in PRESET[NAME].
|
||||||
|
preset_detail() {
|
||||||
|
[[ -n ${CONFIG[$1]+set} ]] || return 1
|
||||||
|
REPLY="${CONFIG[$1]}"
|
||||||
|
PRESET[$2]=1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ask for each project detail, except those set in config.env.
|
||||||
|
collect_project_details() {
|
||||||
|
preset_detail PROJECT_NAME name ||
|
||||||
|
prompt_value "Repository name" "" is_valid_repo_name "$HINT_REPO_NAME"
|
||||||
|
PROJECT[name]="$REPLY"
|
||||||
|
preset_detail PROJECT_DESCRIPTION description ||
|
||||||
|
prompt_value "Description (optional)" "" is_valid_description "$HINT_DESCRIPTION"
|
||||||
|
PROJECT[description]="$REPLY"
|
||||||
|
preset_detail PROJECT_VISIBILITY visibility ||
|
||||||
|
prompt_choice "Visibility" private private public
|
||||||
|
PROJECT[visibility]="$REPLY"
|
||||||
|
preset_detail GITEA_OWNER gitea_owner ||
|
||||||
|
prompt_value "Gitea owner (user or organization)" "" is_valid_gitea_owner "$HINT_GITEA_OWNER"
|
||||||
|
PROJECT[gitea_owner]="$REPLY"
|
||||||
|
collect_github_details
|
||||||
|
preset_detail PROJECT_DIRECTORY directory ||
|
||||||
|
prompt_value "Local directory" "./${PROJECT[name]}" is_valid_directory "$HINT_DIRECTORY"
|
||||||
|
PROJECT[directory]="$REPLY"
|
||||||
|
if preset_detail ENABLE_PLAN_GATE is_plan_gate_enabled; then
|
||||||
|
[[ $REPLY == yes ]] && REPLY=1 || REPLY=0
|
||||||
|
else
|
||||||
|
prompt_yes_no "Enable the plan gate" n
|
||||||
|
fi
|
||||||
|
PROJECT[is_plan_gate_enabled]="$REPLY"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Whether GitHub is used, and its owner. A GITHUB_OWNER set while GitHub is
|
||||||
|
# not used is ignored, with a warning.
|
||||||
|
collect_github_details() {
|
||||||
|
if preset_detail USE_GITHUB has_github; then
|
||||||
|
[[ $REPLY == yes ]] && REPLY=1 || REPLY=0
|
||||||
|
else
|
||||||
|
prompt_yes_no "Also create a GitHub repository (applies the AGPL license)" y
|
||||||
|
fi
|
||||||
|
PROJECT[has_github]="$REPLY"
|
||||||
|
PROJECT[github_owner]=""
|
||||||
|
if ((PROJECT[has_github])); then
|
||||||
|
preset_detail GITHUB_OWNER github_owner ||
|
||||||
|
prompt_value "GitHub owner (user or organization)" "${CREDENTIALS[GITHUB_USER]:-}" is_valid_github_owner "$HINT_GITHUB_OWNER"
|
||||||
|
PROJECT[github_owner]="$REPLY"
|
||||||
|
elif [[ -n ${CONFIG[GITHUB_OWNER]+set} ]]; then
|
||||||
|
warn "GITHUB_OWNER in $CONFIG_FILE is ignored because GitHub is not used"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# source_note NAME: the marker shown after a value that came from config.env.
|
||||||
|
source_note() {
|
||||||
|
if [[ -n ${PRESET[$1]:-} ]]; then
|
||||||
|
printf ' (from config.env)'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_summary() {
|
||||||
|
say ""
|
||||||
|
say "$PROJECT_NAME $VERSION"
|
||||||
|
say "Collected details:"
|
||||||
|
say " Repository : ${PROJECT[name]}$(source_note name) (${PROJECT[visibility]}$(source_note visibility))"
|
||||||
|
say " Description : ${PROJECT[description]:-(none)}$(source_note description)"
|
||||||
|
say " Gitea : ${CONFIG[GITEA_URL]}/${PROJECT[gitea_owner]}/${PROJECT[name]}$(source_note gitea_owner)"
|
||||||
|
if ((PROJECT[has_github])); then
|
||||||
|
say " GitHub : ${CONFIG[GITHUB_WEB_URL]}/${PROJECT[github_owner]}/${PROJECT[name]} (AGPL license applied)$(source_note github_owner)"
|
||||||
|
else
|
||||||
|
say " GitHub : not used$(source_note has_github)"
|
||||||
|
fi
|
||||||
|
say " Directory : ${PROJECT[directory]}$(source_note directory)"
|
||||||
|
say " Plan gate : $(yes_no "${PROJECT[is_plan_gate_enabled]}")$(source_note 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,52 @@
|
|||||||
|
# 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 project is in ${PROJECT[directory]}. Nothing was committed there: review it, then work on a branch."
|
||||||
|
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 and the README Gitea adds), so the next run offers to reuse it."
|
||||||
|
say "A directory, remotes and submodule created so far are used again by the next run; you are asked before an existing directory or file is touched."
|
||||||
|
say "Nothing is deleted automatically. To start over, delete the repositories above in the web interface and the project directory by hand."
|
||||||
|
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,72 @@
|
|||||||
|
# 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_framework_repo, 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}$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# OWNER/NAME of the framework repository on Gitea.
|
||||||
|
is_valid_framework_repo() {
|
||||||
|
[[ $1 =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ && $1 != */.. && $1 != ../* && $1 != ./* && $1 != */. ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
+200
-14
@@ -11,12 +11,19 @@
|
|||||||
# shellcheck disable=SC2016,SC2034 # stub and snippet text is literal on purpose; OUT, ERR and STATUS are read by the test files
|
# 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)"
|
REPO_ROOT="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd)"
|
||||||
readonly REPO_ROOT
|
readonly REPO_ROOT
|
||||||
readonly SCRIPT="$REPO_ROOT/src/create-project.sh"
|
readonly SRC_DIR="$REPO_ROOT/src"
|
||||||
|
readonly SCRIPT="$SRC_DIR/create-project.sh"
|
||||||
|
|
||||||
# Distinctive fake credentials; the tests search all output for them.
|
# Distinctive fake credentials; the tests search all output for them.
|
||||||
readonly FAKE_GITEA_TOKEN="giteaFAKEtoken1234567890"
|
readonly FAKE_GITEA_TOKEN="giteaFAKEtoken1234567890"
|
||||||
readonly FAKE_GITHUB_PAT="ghpFAKEtoken1234567890"
|
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'
|
||||||
|
|
||||||
|
SHARED_REMOTES=""
|
||||||
TESTS_RUN=0
|
TESTS_RUN=0
|
||||||
TESTS_FAILED=0
|
TESTS_FAILED=0
|
||||||
CURRENT_TEST=""
|
CURRENT_TEST=""
|
||||||
@@ -59,6 +66,14 @@ assert_not_contains() {
|
|||||||
fi
|
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() {
|
assert_file_exists() {
|
||||||
check
|
check
|
||||||
if [[ ! -e $2 ]]; then
|
if [[ ! -e $2 ]]; then
|
||||||
@@ -77,6 +92,61 @@ assert_file_missing() {
|
|||||||
new_workdir() {
|
new_workdir() {
|
||||||
WORK="$(mktemp -d "${TMPDIR:-/tmp}/repofoundry-test.XXXXXX")"
|
WORK="$(mktemp -d "${TMPDIR:-/tmp}/repofoundry-test.XXXXXX")"
|
||||||
mkdir -p "$WORK/bin" "$WORK/tmp"
|
mkdir -p "$WORK/bin" "$WORK/tmp"
|
||||||
|
write_gitconfig
|
||||||
|
}
|
||||||
|
|
||||||
|
# write_gitconfig: the git configuration every test run uses instead of the
|
||||||
|
# real user's (GIT_CONFIG_GLOBAL), so no test depends on or changes it. The
|
||||||
|
# remote addresses of Gitea and the framework are redirected to local bare
|
||||||
|
# repositories (see setup_local_remotes); nothing reaches the network.
|
||||||
|
write_gitconfig() {
|
||||||
|
cat >"$WORK/gitconfig" <<EOF
|
||||||
|
[user]
|
||||||
|
name = Test User
|
||||||
|
email = test@example.test
|
||||||
|
[protocol "file"]
|
||||||
|
allow = always
|
||||||
|
[url "file://$WORK/remote/"]
|
||||||
|
insteadOf = https://git.example.test/
|
||||||
|
[url "file://$WORK/remote/"]
|
||||||
|
insteadOf = ssh://git@git.example.test:10022/
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# ensure_shared_remotes: build, once per run of the suite, the local bare
|
||||||
|
# repositories that stand in for Gitea (TirSystem/my-app.git, holding the
|
||||||
|
# license commit) and for the framework (a copy of the real one).
|
||||||
|
ensure_shared_remotes() {
|
||||||
|
if [[ -n $SHARED_REMOTES && -d $SHARED_REMOTES ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
SHARED_REMOTES="$(mktemp -d "${TMPDIR:-/tmp}/repofoundry-remotes.XXXXXX")"
|
||||||
|
mkdir -p "$SHARED_REMOTES/TirSystem"
|
||||||
|
git clone -q --bare "$REPO_ROOT/framework" "$SHARED_REMOTES/TirSystem/SQA-QC-Framework.git"
|
||||||
|
git init -q --bare "$SHARED_REMOTES/TirSystem/my-app.git"
|
||||||
|
git init -q "$SHARED_REMOTES/seed"
|
||||||
|
git -C "$SHARED_REMOTES/seed" symbolic-ref HEAD refs/heads/main
|
||||||
|
printf 'GNU AFFERO GENERAL PUBLIC LICENSE (test copy)\n' >"$SHARED_REMOTES/seed/LICENSE"
|
||||||
|
git -C "$SHARED_REMOTES/seed" add LICENSE
|
||||||
|
git -c user.name=Seed -c user.email=seed@example.test -C "$SHARED_REMOTES/seed" commit -q -m "Initial commit"
|
||||||
|
git -C "$SHARED_REMOTES/seed" push -q "$SHARED_REMOTES/TirSystem/my-app.git" main
|
||||||
|
find "$SHARED_REMOTES/seed" \( -type f -o -type l \) -delete
|
||||||
|
find "$SHARED_REMOTES/seed" -depth -type d -exec rmdir {} +
|
||||||
|
}
|
||||||
|
|
||||||
|
# remove_shared_remotes: delete the shared repositories at the end of the run.
|
||||||
|
remove_shared_remotes() {
|
||||||
|
if [[ -n $SHARED_REMOTES && -d $SHARED_REMOTES ]]; then
|
||||||
|
find "$SHARED_REMOTES" \( -type f -o -type l \) -delete
|
||||||
|
find "$SHARED_REMOTES" -depth -type d -exec rmdir {} +
|
||||||
|
fi
|
||||||
|
SHARED_REMOTES=""
|
||||||
|
}
|
||||||
|
|
||||||
|
# setup_local_remotes: this test's own copy of the local remotes.
|
||||||
|
setup_local_remotes() {
|
||||||
|
ensure_shared_remotes
|
||||||
|
cp -R "$SHARED_REMOTES" "$WORK/remote"
|
||||||
}
|
}
|
||||||
|
|
||||||
# remove_workdir: delete the work directory without a recursive rm: files
|
# remove_workdir: delete the work directory without a recursive rm: files
|
||||||
@@ -111,25 +181,137 @@ write_stub() {
|
|||||||
chmod +x "$WORK/bin/$1"
|
chmod +x "$WORK/bin/$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
# write_curl_stub: a curl that records its arguments and configuration and
|
# write_curl_stub: a curl that records every call and answers from the
|
||||||
# answers with STUB_CURL_STATUS (default 200) and body STUB_CURL_BODY.
|
# 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() {
|
write_curl_stub() {
|
||||||
write_stub curl '
|
cat >"$WORK/bin/curl" <<'STUB'
|
||||||
printf "%s\n" "$@" >>"$STUB_DIR/curl.args"
|
#!/usr/bin/env bash
|
||||||
out="" cfg=""
|
printf '%s\n' "$@" >>"$STUB_DIR/curl.args"
|
||||||
|
out="" cfg="" data=""
|
||||||
while (($# > 0)); do
|
while (($# > 0)); do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--output) out="$2"; shift 2 ;;
|
--output) out="$2"; shift 2 ;;
|
||||||
--config) cfg="$2"; shift 2 ;;
|
--config) cfg="$2"; shift 2 ;;
|
||||||
|
--data-binary) data="${2#@}"; shift 2 ;;
|
||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
if [[ -n $cfg ]]; then cat "$cfg" >>"$STUB_DIR/curl.config"; fi
|
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:-}"
|
body="${STUB_CURL_BODY:-}"
|
||||||
if [[ -z $body ]]; then body="{\"ok\":true}"; fi
|
if [[ -z $body ]]; then body="{\"ok\":true}"; fi
|
||||||
if [[ -n $out ]]; then printf "%s" "$body" >"$out"; fi
|
if [[ -f $STUB_DIR/routes ]]; then
|
||||||
printf "%s" "${STUB_CURL_STATUS:-200}"
|
status=404
|
||||||
exit "${STUB_CURL_EXIT:-0}"'
|
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
|
||||||
|
# The real ssh reads standard input until it ends; so does this stub. Whatever
|
||||||
|
# is left on the caller's stdin (the answers to later prompts) is lost to it.
|
||||||
|
cat >/dev/null
|
||||||
|
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
|
||||||
|
setup_local_remotes
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
# run_cli STDIN ARGS...: run create-project.sh with answers from STDIN (a
|
||||||
@@ -138,8 +320,11 @@ run_cli() {
|
|||||||
local input="$1"
|
local input="$1"
|
||||||
shift
|
shift
|
||||||
STATUS=0
|
STATUS=0
|
||||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
# Run inside the work directory: a relative project directory such as
|
||||||
"$BASH" "$SCRIPT" "$@" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" ||
|
# ./my-app is then created there, never in the repository.
|
||||||
|
(cd "$WORK" && PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
||||||
|
REPOFOUNDRY_SYNC_WAIT=0 GIT_CONFIG_GLOBAL="$WORK/gitconfig" GIT_CONFIG_NOSYSTEM=1 \
|
||||||
|
"$BASH" "$SCRIPT" "$@" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt") ||
|
||||||
STATUS=$?
|
STATUS=$?
|
||||||
OUT="$(cat "$WORK/out.txt")"
|
OUT="$(cat "$WORK/out.txt")"
|
||||||
ERR="$(cat "$WORK/err.txt")"
|
ERR="$(cat "$WORK/err.txt")"
|
||||||
@@ -154,8 +339,9 @@ run_lib() {
|
|||||||
printf '#!/usr/bin/env bash\nsource "%s"\n' "$SCRIPT"
|
printf '#!/usr/bin/env bash\nsource "%s"\n' "$SCRIPT"
|
||||||
printf '%s\n' "$2"
|
printf '%s\n' "$2"
|
||||||
} >"$WORK/snippet.sh"
|
} >"$WORK/snippet.sh"
|
||||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
(cd "$WORK" && PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
||||||
"$BASH" "$WORK/snippet.sh" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" ||
|
REPOFOUNDRY_SYNC_WAIT=0 GIT_CONFIG_GLOBAL="$WORK/gitconfig" GIT_CONFIG_NOSYSTEM=1 \
|
||||||
|
"$BASH" "$WORK/snippet.sh" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt") ||
|
||||||
STATUS=$?
|
STATUS=$?
|
||||||
OUT="$(cat "$WORK/out.txt")"
|
OUT="$(cat "$WORK/out.txt")"
|
||||||
ERR="$(cat "$WORK/err.txt")"
|
ERR="$(cat "$WORK/err.txt")"
|
||||||
|
|||||||
+9
-3
@@ -23,15 +23,20 @@ failed_checks=0
|
|||||||
|
|
||||||
run_static_checks() {
|
run_static_checks() {
|
||||||
printf '== static checks\n'
|
printf '== static checks\n'
|
||||||
bash -n "$SCRIPT" || failed_checks=$((failed_checks + 1))
|
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
|
if command -v shellcheck >/dev/null 2>&1; then
|
||||||
shellcheck "$SCRIPT" "$TEST_DIR"/*.sh ||
|
# -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))
|
failed_checks=$((failed_checks + 1))
|
||||||
else
|
else
|
||||||
printf 'skipped: shellcheck is not installed\n'
|
printf 'skipped: shellcheck is not installed\n'
|
||||||
fi
|
fi
|
||||||
if command -v shfmt >/dev/null 2>&1; then
|
if command -v shfmt >/dev/null 2>&1; then
|
||||||
shfmt -i 2 -ci -d "$SCRIPT" "$TEST_DIR"/*.sh ||
|
shfmt -i 2 -ci -d "$SCRIPT" "$SRC_DIR"/lib/*.sh "$TEST_DIR"/*.sh ||
|
||||||
failed_checks=$((failed_checks + 1))
|
failed_checks=$((failed_checks + 1))
|
||||||
else
|
else
|
||||||
printf 'skipped: shfmt is not installed\n'
|
printf 'skipped: shfmt is not installed\n'
|
||||||
@@ -63,6 +68,7 @@ for test_file in "$TEST_DIR"/test-*.sh; do
|
|||||||
done < <(declare -F | awk '$3 ~ /^test_/ {print $3}')
|
done < <(declare -F | awk '$3 ~ /^test_/ {print $3}')
|
||||||
done
|
done
|
||||||
|
|
||||||
|
remove_shared_remotes
|
||||||
printf '\n%d checks, %d failed, %d static check(s) failed\n' \
|
printf '\n%d checks, %d failed, %d static check(s) failed\n' \
|
||||||
"$TESTS_RUN" "$TESTS_FAILED" "$failed_checks"
|
"$TESTS_RUN" "$TESTS_FAILED" "$failed_checks"
|
||||||
if ((TESTS_FAILED > 0 || failed_checks > 0)); then
|
if ((TESTS_FAILED > 0 || failed_checks > 0)); then
|
||||||
|
|||||||
+12
-1
@@ -158,10 +158,21 @@ test_example_files_hold_placeholders_only() {
|
|||||||
fail ".env.example has a value for ${line%%=*}; it must be empty"
|
fail ".env.example has a value for ${line%%=*}; it must be empty"
|
||||||
fi
|
fi
|
||||||
done < <(grep -E '^[A-Z_]+=' "$REPO_ROOT/.env.example")
|
done < <(grep -E '^[A-Z_]+=' "$REPO_ROOT/.env.example")
|
||||||
|
# The example holds a placeholder for the Gitea address, so it must be
|
||||||
|
# edited before use: as it is, it is refused with a clear message...
|
||||||
run_lib "" "parse_env_file \"$REPO_ROOT/config.env.example\" CONFIG_KEYS CONFIG
|
run_lib "" "parse_env_file \"$REPO_ROOT/config.env.example\" CONFIG_KEYS CONFIG
|
||||||
validate_config
|
validate_config
|
||||||
echo parsed"
|
echo parsed"
|
||||||
assert_contains "config.env.example is valid" "$OUT" "parsed"
|
assert_status "placeholder address is refused" 1 "$STATUS"
|
||||||
|
assert_contains "names the key" "$ERR" "GITEA_URL"
|
||||||
|
# ...and with a real address in its place the rest of the file is valid.
|
||||||
|
sed -e 's|^GITEA_URL=.*|GITEA_URL=https://git.example.test/|' \
|
||||||
|
-e 's|^GITEA_API_URL=.*|GITEA_API_URL=https://git.example.test/api/v1|' \
|
||||||
|
"$REPO_ROOT/config.env.example" >"$WORK/example.env"
|
||||||
|
run_lib "" "parse_env_file \"$WORK/example.env\" CONFIG_KEYS CONFIG
|
||||||
|
validate_config
|
||||||
|
echo parsed"
|
||||||
|
assert_contains "config.env.example is valid once the address is set" "$OUT" "parsed"
|
||||||
run_lib "" "parse_env_file \"$REPO_ROOT/.env.example\" CREDENTIAL_KEYS CREDENTIALS
|
run_lib "" "parse_env_file \"$REPO_ROOT/.env.example\" CREDENTIAL_KEYS CREDENTIALS
|
||||||
echo parsed"
|
echo parsed"
|
||||||
assert_contains ".env.example parses" "$OUT" "parsed"
|
assert_contains ".env.example parses" "$OUT" "parsed"
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
#!/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
|
||||||
|
# The Gitea account's own repository (with the license commit) is a copy.
|
||||||
|
mkdir -p "$WORK/remote/gitea-user"
|
||||||
|
cp -R "$WORK/remote/TirSystem/my-app.git" "$WORK/remote/gitea-user/my-app.git"
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------- found by the live e2e run
|
||||||
|
|
||||||
|
test_a_repository_this_script_created_earlier_can_be_reused() {
|
||||||
|
# Seen on a real Gitea: creating a repository with a license also adds a
|
||||||
|
# README.md. After a failed mirror step the next run must still reuse it.
|
||||||
|
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"},{"name":"LICENSE","type":"file"}]'
|
||||||
|
run_apply "$ANSWERS_GITHUB"$'y\ny\n'
|
||||||
|
assert_status "LICENSE and README.md" 0 "$STATUS"
|
||||||
|
assert_not_contains "not created again" "$(calls)" "$GITEA_REPO_CALL"
|
||||||
|
assert_contains "reported" "$OUT" "Gitea repository : reused"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_other_files_still_count_as_content() {
|
||||||
|
local listing
|
||||||
|
for listing in '[{"name":"README.md","type":"file"}]' \
|
||||||
|
'[{"name":"LICENSE","type":"file"},{"name":"README.md","type":"file"},{"name":"main.c","type":"file"}]' \
|
||||||
|
'[{"name":"LICENSE","type":"file"},{"name":"src","type":"dir"}]'; do
|
||||||
|
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|$listing"
|
||||||
|
run_dry "$ANSWERS_GITHUB"
|
||||||
|
assert_status "content: $listing" 1 "$STATUS"
|
||||||
|
assert_contains "refused" "$ERR" "already exists and has content"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
test_children_never_eat_the_answers_meant_for_later_prompts() {
|
||||||
|
# The real ssh reads standard input until it ends; the stub does too. The
|
||||||
|
# script closes stdin for ssh, git, curl and the framework scripts, so the
|
||||||
|
# answers that follow the SSH test still reach the later prompts.
|
||||||
|
setup_hosts
|
||||||
|
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||||
|
assert_status "the final question is still answered" 0 "$STATUS"
|
||||||
|
assert_contains "created" "$OUT" "Done. This is what exists now:"
|
||||||
|
assert_not_contains "no lost input" "$ERR" "no input available"
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-local.sh - tests for the local project (MIL-003): the directory, the
|
||||||
|
# git repository and its remotes, the framework submodule, skills, hooks and
|
||||||
|
# plan gate, and the templates. Real git runs here, against local bare
|
||||||
|
# repositories that stand in for Gitea and the framework (git rewrites the
|
||||||
|
# remote addresses, see write_gitconfig); only the two hosts' APIs are stubs.
|
||||||
|
# Sourced by run-tests.sh.
|
||||||
|
|
||||||
|
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
|
||||||
|
|
||||||
|
# local_answers DIR GITHUB GATE: the prompt answers; the result is in
|
||||||
|
# LOCAL_ANSWERS (kept in a variable because $(...) would drop the last newline).
|
||||||
|
local_answers() {
|
||||||
|
if [[ $2 == y ]]; then
|
||||||
|
printf -v LOCAL_ANSWERS 'my-app\nA test app\n\nTirSystem\ny\nacme-org\n%s\n%s\n' "$1" "$3"
|
||||||
|
else
|
||||||
|
printf -v LOCAL_ANSWERS 'my-app\n\n\nTirSystem\nn\n%s\n%s\n' "$1" "$3"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# project_git DIR ARGS...: git in the project with the tests' own config.
|
||||||
|
project_git() {
|
||||||
|
local dir="$1"
|
||||||
|
shift
|
||||||
|
GIT_CONFIG_GLOBAL="$WORK/gitconfig" GIT_CONFIG_NOSYSTEM=1 git -C "$dir" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# files_with_secret DIR: any file below DIR (the .git folder included) that
|
||||||
|
# holds one of the fake tokens.
|
||||||
|
files_with_secret() {
|
||||||
|
grep -rlF -e "$FAKE_GITEA_TOKEN" -e "$FAKE_GITHUB_PAT" "$1" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly SSH_FRAMEWORK_URL="ssh://git@git.example.test:10022/TirSystem/SQA-QC-Framework.git"
|
||||||
|
|
||||||
|
# ----------------------------------------------------- directory and remotes
|
||||||
|
|
||||||
|
test_local_project_gets_credential_free_remotes_and_the_license_history() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||||
|
assert_status "apply" 0 "$STATUS"
|
||||||
|
assert_file_exists "directory created" "$dir/.git"
|
||||||
|
assert_eq "branch is main" "main" "$(project_git "$dir" symbolic-ref --short HEAD)"
|
||||||
|
assert_eq "origin over SSH, no credential" "ssh://git@git.example.test:10022/TirSystem/my-app.git" "$(project_git "$dir" config --get remote.origin.url)"
|
||||||
|
assert_eq "github remote over HTTPS, no credential" "https://github.com/acme-org/my-app.git" "$(project_git "$dir" config --get remote.github.url)"
|
||||||
|
assert_eq "the license commit is the whole history" "1" "$(project_git "$dir" rev-list --count HEAD)"
|
||||||
|
assert_eq "it is the Gitea commit" "Initial commit" "$(project_git "$dir" log -1 --format=%s)"
|
||||||
|
assert_file_exists "LICENSE from Gitea" "$dir/LICENSE"
|
||||||
|
assert_eq "branch follows origin" "origin" "$(project_git "$dir" config --get branch.main.remote)"
|
||||||
|
assert_eq "no token in any file of the project" "" "$(files_with_secret "$dir")"
|
||||||
|
assert_contains "reported" "$OUT" "Local project : created $dir (origin over SSH)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_gitea_only_project_has_no_github_remote_and_no_commit() {
|
||||||
|
setup_hosts
|
||||||
|
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||||
|
local dir="$WORK/project"
|
||||||
|
local_answers "$dir" n n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||||
|
assert_status "apply" 0 "$STATUS"
|
||||||
|
assert_eq "origin" "ssh://git@git.example.test:10022/TirSystem/my-app.git" "$(project_git "$dir" config --get remote.origin.url)"
|
||||||
|
assert_eq "no github remote" "" "$(project_git "$dir" config --get remote.github.url || true)"
|
||||||
|
assert_file_missing "no license file" "$dir/LICENSE"
|
||||||
|
assert_eq "no commit was made" "0" "$(project_git "$dir" rev-list --all --count)"
|
||||||
|
assert_eq "no token in any file" "" "$(files_with_secret "$dir")"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_existing_directory_is_used_only_after_a_yes() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
printf 'mine\n' >"$dir/keep.txt"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\nn\n'
|
||||||
|
assert_status "answered no" 1 "$STATUS"
|
||||||
|
assert_contains "asked" "$ERR" "The directory $dir already exists and already has files. Use it"
|
||||||
|
assert_contains "stopped" "$ERR" "stopped: choose another directory or remove this one"
|
||||||
|
assert_file_missing "nothing added to the directory" "$dir/.git"
|
||||||
|
assert_not_contains "no repository created before the answer" "$(calls)" "POST"
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
|
||||||
|
assert_status "answered yes" 0 "$STATUS"
|
||||||
|
assert_eq "the existing file is untouched" "mine" "$(cat "$dir/keep.txt")"
|
||||||
|
assert_file_exists "project created beside it" "$dir/.git"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_an_empty_existing_directory_is_also_confirmed() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
|
||||||
|
assert_status "empty directory, yes" 0 "$STATUS"
|
||||||
|
assert_contains "asked" "$ERR" "already exists and is empty. Use it"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_a_file_that_would_be_overwritten_by_the_license_history_is_kept() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
printf 'my own license\n' >"$dir/LICENSE"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
|
||||||
|
assert_status "git refuses to overwrite" 1 "$STATUS"
|
||||||
|
assert_contains "says why" "$ERR" "git would overwrite files in $dir"
|
||||||
|
assert_eq "the file is intact" "my own license" "$(cat "$dir/LICENSE")"
|
||||||
|
assert_contains "step failed" "$OUT" "Local project : FAILED"
|
||||||
|
assert_contains "later steps not attempted" "$OUT" "Framework : not attempted"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_a_remote_with_another_address_is_never_replaced() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
project_git "$dir" init -q
|
||||||
|
project_git "$dir" remote add origin https://elsewhere.example.test/x/y.git
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
|
||||||
|
assert_status "different origin" 1 "$STATUS"
|
||||||
|
assert_contains "says so" "$ERR" "the remote 'origin' in $dir already points to https://elsewhere.example.test/x/y.git"
|
||||||
|
assert_eq "origin unchanged" "https://elsewhere.example.test/x/y.git" "$(project_git "$dir" config --get remote.origin.url)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- framework
|
||||||
|
|
||||||
|
test_framework_is_a_submodule_and_installed_in_order() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||||
|
assert_status "apply" 0 "$STATUS"
|
||||||
|
assert_eq "submodule address" "$SSH_FRAMEWORK_URL" "$(project_git "$dir" config -f .gitmodules --get submodule.framework.url)"
|
||||||
|
assert_file_exists "submodule content" "$dir/framework/scripts/install-skills.sh"
|
||||||
|
assert_file_exists "skills installed" "$dir/.claude/skills/coding-conventions/SKILL.md"
|
||||||
|
assert_file_exists "skills for the other harness" "$dir/.agents/skills/.framework-skills"
|
||||||
|
assert_eq "hooks path" "framework/githooks" "$(project_git "$dir" config --local --get core.hooksPath)"
|
||||||
|
assert_eq "plan gate off" "" "$(project_git "$dir" config --local --get planGate.enabled || true)"
|
||||||
|
assert_contains "reported" "$OUT" "Framework : created $SSH_FRAMEWORK_URL"
|
||||||
|
assert_contains "reported once" "$OUT" "Skills and hooks : created (skills installed; hooks installed; plan gate off)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_skills_and_hooks_are_installed_once() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||||
|
assert_status "first run" 0 "$STATUS"
|
||||||
|
run_lib "" "PROJECT[directory]='$dir'
|
||||||
|
PROJECT[is_plan_gate_enabled]=0
|
||||||
|
install_framework
|
||||||
|
echo \"\${STATE[skills_note]}|\${STATE[hooks_note]}\""
|
||||||
|
assert_status "second pass" 0 "$STATUS"
|
||||||
|
assert_eq "nothing installed twice" "skills already installed|hooks already installed" "$OUT"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_the_plan_gate_is_optional_and_refuses_unplanned_commits() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project" out
|
||||||
|
local_answers "$dir" y y
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||||
|
assert_status "apply with the plan gate" 0 "$STATUS"
|
||||||
|
assert_eq "plan gate on" "true" "$(project_git "$dir" config --local --get planGate.enabled)"
|
||||||
|
assert_contains "reported" "$OUT" "plan gate on)"
|
||||||
|
# The hooks refuse a commit on main, so work on a branch.
|
||||||
|
project_git "$dir" checkout -q -b work
|
||||||
|
mkdir -p "$dir/src"
|
||||||
|
printf 'x\n' >"$dir/src/x.txt"
|
||||||
|
project_git "$dir" add src/x.txt
|
||||||
|
out="$(project_git "$dir" commit -q -m "unplanned change" 2>&1 || true)"
|
||||||
|
assert_contains "refused without a task trailer" "$out" "plan-first gate: no 'Task: MIL-NNN#N' trailer"
|
||||||
|
assert_eq "no commit was made" "1" "$(project_git "$dir" rev-list --count HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_without_the_plan_gate_the_same_commit_is_allowed() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||||
|
project_git "$dir" checkout -q -b work
|
||||||
|
mkdir -p "$dir/src"
|
||||||
|
printf 'x\n' >"$dir/src/x.txt"
|
||||||
|
project_git "$dir" add src/x.txt
|
||||||
|
project_git "$dir" commit -q -m "change"
|
||||||
|
assert_eq "commit made" "2" "$(project_git "$dir" rev-list --count HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_the_hooks_refuse_a_commit_on_main() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project" out
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||||
|
printf 'x\n' >"$dir/x.txt"
|
||||||
|
project_git "$dir" add x.txt
|
||||||
|
out="$(project_git "$dir" commit -q -m "on main" 2>&1 || true)"
|
||||||
|
assert_contains "refused on main" "$out" "refusing to commit directly on 'main'"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_an_existing_hooks_path_is_not_replaced_without_a_yes() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
project_git "$dir" init -q
|
||||||
|
project_git "$dir" config core.hooksPath .githooks
|
||||||
|
local_answers "$dir" y y
|
||||||
|
# create now, use the directory, keep the hooks path
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\ny\nn\n'
|
||||||
|
assert_status "run continues" 0 "$STATUS"
|
||||||
|
assert_contains "asked" "$ERR" "core.hooksPath is already '.githooks'. Replace it with framework/githooks"
|
||||||
|
assert_eq "hooks path kept" ".githooks" "$(project_git "$dir" config --local --get core.hooksPath)"
|
||||||
|
assert_contains "reported" "$OUT" "kept the existing hooks path '.githooks'"
|
||||||
|
assert_contains "the plan gate is not on without the hooks" "$ERR" "the plan gate is not enabled because the framework hooks were not installed"
|
||||||
|
# Answering yes replaces it.
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\ny\ny\n'
|
||||||
|
assert_eq "hooks path replaced after a yes" "framework/githooks" "$(project_git "$dir" config --local --get core.hooksPath)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_a_global_hooks_path_is_reported_not_changed() {
|
||||||
|
setup_hosts
|
||||||
|
git config --file "$WORK/gitconfig" core.hooksPath global-hooks-dir
|
||||||
|
local dir="$WORK/project"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||||
|
assert_status "apply" 0 "$STATUS"
|
||||||
|
assert_contains "warns" "$ERR" "your global core.hooksPath is 'global-hooks-dir'"
|
||||||
|
assert_eq "the global setting is untouched" "global-hooks-dir" "$(git config --file "$WORK/gitconfig" --get core.hooksPath)"
|
||||||
|
assert_eq "the project has its own" "framework/githooks" "$(project_git "$dir" config --local --get core.hooksPath)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_a_project_root_in_the_environment_cannot_redirect_the_framework_scripts() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
PROJECT_ROOT="$WORK/elsewhere" run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||||
|
assert_status "apply" 0 "$STATUS"
|
||||||
|
assert_file_missing "nothing installed where the environment pointed" "$WORK/elsewhere"
|
||||||
|
assert_file_exists "installed in the project" "$dir/.claude/skills/.framework-skills"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- templates
|
||||||
|
|
||||||
|
test_templates_are_copied() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||||
|
assert_status "apply" 0 "$STATUS"
|
||||||
|
assert_eq "AGENTS.md is the template" "$(cat "$dir/framework/templates/AGENTS-template.md")" "$(cat "$dir/AGENTS.md")"
|
||||||
|
assert_eq "registry is the template" "$(cat "$dir/framework/templates/artifact-registry-template.md")" "$(cat "$dir/docs/artifact-registry.md")"
|
||||||
|
assert_contains "reported" "$OUT" "Templates : created (copied AGENTS.md; copied docs/artifact-registry.md)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_existing_template_targets_are_replaced_only_after_a_yes() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
mkdir -p "$dir/docs"
|
||||||
|
printf 'my agents file\n' >"$dir/AGENTS.md"
|
||||||
|
printf 'my registry\n' >"$dir/docs/artifact-registry.md"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
# create now, use the directory, keep AGENTS.md, replace the registry
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\ny\nn\ny\n'
|
||||||
|
assert_status "apply" 0 "$STATUS"
|
||||||
|
assert_contains "asked about AGENTS.md" "$ERR" "AGENTS.md already exists. Replace it with the framework template"
|
||||||
|
assert_eq "AGENTS.md kept" "my agents file" "$(cat "$dir/AGENTS.md")"
|
||||||
|
assert_eq "registry replaced after a yes" "$(cat "$dir/framework/templates/artifact-registry-template.md")" "$(cat "$dir/docs/artifact-registry.md")"
|
||||||
|
assert_contains "reported" "$OUT" "kept existing AGENTS.md; copied docs/artifact-registry.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- SSH and errors
|
||||||
|
|
||||||
|
test_without_ssh_the_run_stops_unless_the_framework_is_skipped() {
|
||||||
|
setup_hosts
|
||||||
|
write_ssh_stub 255
|
||||||
|
local dir="$WORK/project"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\nn\n'
|
||||||
|
assert_status "answered no" 1 "$STATUS"
|
||||||
|
assert_contains "explains" "$ERR" "SSH to Gitea (git.example.test port 10022) did not work, so the framework cannot be added"
|
||||||
|
assert_contains "says what to do" "$ERR" "stopped: set up SSH access to Gitea (see the README) and run again"
|
||||||
|
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||||
|
assert_file_missing "no directory" "$dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_without_ssh_the_framework_steps_are_skipped_after_a_yes() {
|
||||||
|
setup_hosts
|
||||||
|
write_ssh_stub 255
|
||||||
|
local dir="$WORK/project"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
|
||||||
|
assert_status "continue without the framework" 0 "$STATUS"
|
||||||
|
assert_eq "origin over HTTPS, no credential" "https://git.example.test/TirSystem/my-app.git" "$(project_git "$dir" config --get remote.origin.url)"
|
||||||
|
assert_eq "the license history arrived over HTTPS" "1" "$(project_git "$dir" rev-list --count HEAD)"
|
||||||
|
assert_eq "no token in any file" "" "$(files_with_secret "$dir")"
|
||||||
|
assert_eq "no temporary files left" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||||
|
assert_contains "framework skipped" "$OUT" "Framework : skipped (no SSH access to Gitea)"
|
||||||
|
assert_contains "skills and hooks skipped" "$OUT" "Skills and hooks : skipped (no SSH access to Gitea)"
|
||||||
|
assert_contains "templates skipped" "$OUT" "Templates : skipped (no SSH access to Gitea)"
|
||||||
|
assert_file_missing "no submodule" "$dir/.gitmodules"
|
||||||
|
assert_file_missing "no AGENTS.md" "$dir/AGENTS.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_a_failed_submodule_gives_an_actionable_message() {
|
||||||
|
setup_hosts
|
||||||
|
find "$WORK/remote/TirSystem/SQA-QC-Framework.git" \( -type f -o -type l \) -delete
|
||||||
|
find "$WORK/remote/TirSystem/SQA-QC-Framework.git" -depth -type d -exec rmdir {} +
|
||||||
|
local dir="$WORK/project"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||||
|
assert_status "no framework repository" 1 "$STATUS"
|
||||||
|
assert_contains "names the address" "$ERR" "git could not add the framework from $SSH_FRAMEWORK_URL"
|
||||||
|
assert_contains "says how to test the access" "$ERR" "ssh -p 10022 -T git@git.example.test"
|
||||||
|
assert_contains "step failed" "$OUT" "Framework : FAILED"
|
||||||
|
assert_contains "the rest not attempted" "$OUT" "Skills and hooks : not attempted"
|
||||||
|
assert_contains "the project itself exists" "$OUT" "Local project : created"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_the_framework_repository_is_configurable() {
|
||||||
|
setup_hosts
|
||||||
|
mkdir -p "$WORK/remote/Other"
|
||||||
|
cp -R "$WORK/remote/TirSystem/SQA-QC-Framework.git" "$WORK/remote/Other/Framework.git"
|
||||||
|
printf 'FRAMEWORK_REPO=Other/Framework\n' >>"$WORK/config.env"
|
||||||
|
local dir="$WORK/project"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||||
|
assert_status "apply" 0 "$STATUS"
|
||||||
|
assert_eq "submodule address" "ssh://git@git.example.test:10022/Other/Framework.git" "$(project_git "$dir" config -f .gitmodules --get submodule.framework.url)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_framework_repo_must_look_like_owner_and_name() {
|
||||||
|
local value
|
||||||
|
for value in "nope" "a/b/c" "../x" "a/.." "a b/c" "/x"; do
|
||||||
|
printf 'GITEA_URL=https://git.example.test\nFRAMEWORK_REPO=%s\n' "$value" >"$WORK/c.env"
|
||||||
|
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
|
||||||
|
validate_config"
|
||||||
|
assert_status "FRAMEWORK_REPO=$value" 1 "$STATUS"
|
||||||
|
assert_contains "message" "$ERR" "FRAMEWORK_REPO"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- the plan
|
||||||
|
|
||||||
|
test_the_dry_run_plan_describes_the_local_steps_and_creates_nothing() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
local_answers "$dir" y y
|
||||||
|
run_dry "$LOCAL_ANSWERS"
|
||||||
|
assert_status "dry run" 0 "$STATUS"
|
||||||
|
assert_contains "project" "$OUT" "Local project : create $dir (new directory), git on main, no commit"
|
||||||
|
assert_contains "framework" "$OUT" "Framework : add $SSH_FRAMEWORK_URL as a submodule"
|
||||||
|
assert_contains "skills and hooks" "$OUT" "Skills and hooks : install once; plan gate yes"
|
||||||
|
assert_contains "templates" "$OUT" "Templates : AGENTS.md and docs/artifact-registry.md (you are asked before a file is replaced)"
|
||||||
|
assert_file_missing "nothing created" "$dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_the_plan_marks_an_existing_directory_and_missing_ssh() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
printf 'x\n' >"$dir/a.txt"
|
||||||
|
write_ssh_stub 255
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_dry "$LOCAL_ANSWERS"
|
||||||
|
assert_contains "existing directory" "$OUT" "use the existing directory $dir, which has files (you will be asked)"
|
||||||
|
assert_contains "no SSH" "$OUT" "NOT possible without SSH to Gitea; you will be asked whether to go on without it"
|
||||||
|
assert_contains "HTTPS origin" "$OUT" "will use HTTPS (SSH test: failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_a_project_path_that_is_a_file_is_refused_in_the_preflight() {
|
||||||
|
setup_hosts
|
||||||
|
local dir="$WORK/project"
|
||||||
|
printf 'x\n' >"$dir"
|
||||||
|
local_answers "$dir" y n
|
||||||
|
run_dry "$LOCAL_ANSWERS"
|
||||||
|
assert_status "path is a file" 1 "$STATUS"
|
||||||
|
assert_contains "message" "$ERR" "already exists and is not a directory"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ helpers
|
||||||
|
|
||||||
|
test_remote_addresses_are_built_from_the_configuration() {
|
||||||
|
run_lib "" 'CONFIG[GITEA_URL]=https://git.example.test/sub
|
||||||
|
CONFIG[GITEA_SSH_PORT]=2222 CONFIG[FRAMEWORK_REPO]=Org/Fw CONFIG[GITHUB_WEB_URL]=https://github.com
|
||||||
|
PROJECT[gitea_owner]=TirSystem PROJECT[github_owner]=acme PROJECT[name]=my-app
|
||||||
|
STATE[is_ssh_ok]=1
|
||||||
|
origin_url; echo
|
||||||
|
STATE[is_ssh_ok]=0
|
||||||
|
origin_url; echo
|
||||||
|
github_remote_url; echo
|
||||||
|
framework_url; echo
|
||||||
|
gitea_host; echo'
|
||||||
|
assert_eq "addresses" $'ssh://git@git.example.test:2222/TirSystem/my-app.git\nhttps://git.example.test/sub/TirSystem/my-app.git\nhttps://github.com/acme/my-app.git\nssh://git@git.example.test:2222/Org/Fw.git\ngit.example.test' "$OUT"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_the_https_fetch_hands_the_token_over_through_the_environment_only() {
|
||||||
|
# A stub git plays the part of the server asking for credentials: it runs
|
||||||
|
# the GIT_ASKPASS helper the way git does and records the answers.
|
||||||
|
local real_git
|
||||||
|
real_git="$(command -v git)"
|
||||||
|
write_stub git "
|
||||||
|
if [[ \$* == *'config --get remote.origin.url'* ]]; then echo https://git.example.test/TirSystem/my-app.git; exit 0; fi
|
||||||
|
if [[ \$* == *fetch* ]]; then
|
||||||
|
printf '%s\n' \"\$@\" >>\"\$STUB_DIR/git.args\"
|
||||||
|
\"\$GIT_ASKPASS\" 'Username for https://git.example.test: ' >>\"\$STUB_DIR/askpass.out\"
|
||||||
|
\"\$GIT_ASKPASS\" 'Password for https://git.example.test: ' >>\"\$STUB_DIR/askpass.out\"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec '$real_git' \"\$@\""
|
||||||
|
run_lib "" "setup_temp_dir
|
||||||
|
STATE[gitea_login]=gitea-user
|
||||||
|
CREDENTIALS[GITEA_TOKEN]='$FAKE_GITEA_TOKEN'
|
||||||
|
fetch_origin '$WORK'
|
||||||
|
cleanup"
|
||||||
|
assert_status "fetch" 0 "$STATUS"
|
||||||
|
assert_eq "user name and token reach git" $'gitea-user\n'"$FAKE_GITEA_TOKEN" "$(cat "$WORK/askpass.out")"
|
||||||
|
assert_not_contains "token not on the git command line" "$(cat "$WORK/git.args")" "$FAKE_GITEA_TOKEN"
|
||||||
|
assert_eq "the helper is removed" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_a_failed_https_fetch_is_reported_to_the_caller() {
|
||||||
|
write_stub git "
|
||||||
|
if [[ \$* == *'config --get remote.origin.url'* ]]; then echo https://git.example.test/TirSystem/my-app.git; exit 0; fi
|
||||||
|
if [[ \$* == *fetch* ]]; then exit 128; fi
|
||||||
|
exit 0"
|
||||||
|
run_lib "" "setup_temp_dir
|
||||||
|
STATE[gitea_login]=gitea-user
|
||||||
|
CREDENTIALS[GITEA_TOKEN]='$FAKE_GITEA_TOKEN'
|
||||||
|
if fetch_origin '$WORK'; then echo ok; else echo failed; fi
|
||||||
|
cleanup"
|
||||||
|
assert_eq "failure passed on" "failed" "$OUT"
|
||||||
|
assert_eq "the helper is removed" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-presets.sh - tests for the project details preset in config.env
|
||||||
|
# (MIL-004): a detail that is set there is not asked, an invalid one stops
|
||||||
|
# the run, and the confirmations stay interactive. Sourced by run-tests.sh.
|
||||||
|
|
||||||
|
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
|
||||||
|
|
||||||
|
# The eight keys with a value that differs from the answer given when asked.
|
||||||
|
readonly PRESET_ALL='PROJECT_NAME=preset-app
|
||||||
|
PROJECT_DESCRIPTION=From the config
|
||||||
|
PROJECT_VISIBILITY=public
|
||||||
|
GITEA_OWNER=PresetOrg
|
||||||
|
USE_GITHUB=yes
|
||||||
|
GITHUB_OWNER=preset-gh
|
||||||
|
PROJECT_DIRECTORY=./preset-dir
|
||||||
|
ENABLE_PLAN_GATE=yes'
|
||||||
|
|
||||||
|
# The same details as ANSWERS_GITHUB, so a run with all of them preset is the
|
||||||
|
# same run with no answers.
|
||||||
|
readonly PRESET_LIKE_ANSWERS='PROJECT_NAME=my-app
|
||||||
|
PROJECT_DESCRIPTION=A test app
|
||||||
|
PROJECT_VISIBILITY=private
|
||||||
|
GITEA_OWNER=TirSystem
|
||||||
|
USE_GITHUB=yes
|
||||||
|
GITHUB_OWNER=acme-org
|
||||||
|
PROJECT_DIRECTORY=./my-app
|
||||||
|
ENABLE_PLAN_GATE=no'
|
||||||
|
|
||||||
|
# collect_with PRESET_LINES INPUT: parse a config holding the preset lines,
|
||||||
|
# collect the details and print them one per line.
|
||||||
|
collect_with() {
|
||||||
|
printf '%s\n' "$1" >"$WORK/preset.env"
|
||||||
|
run_lib "$2" 'parse_env_file "'"$WORK"'/preset.env" CONFIG_KEYS CONFIG
|
||||||
|
validate_project_presets
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Each key, the answers a run gives for the other seven details, and the
|
||||||
|
# prompt that must not be shown for the key.
|
||||||
|
test_each_key_is_used_and_not_asked() {
|
||||||
|
local key value field label line input
|
||||||
|
local -A answer=([PROJECT_NAME]=asked-app [PROJECT_DESCRIPTION]="Asked description"
|
||||||
|
[PROJECT_VISIBILITY]=private [GITEA_OWNER]=AskedOrg [USE_GITHUB]=y
|
||||||
|
[GITHUB_OWNER]=asked-gh [PROJECT_DIRECTORY]=./asked-dir [ENABLE_PLAN_GATE]=n)
|
||||||
|
local order=(PROJECT_NAME PROJECT_DESCRIPTION PROJECT_VISIBILITY GITEA_OWNER
|
||||||
|
USE_GITHUB GITHUB_OWNER PROJECT_DIRECTORY ENABLE_PLAN_GATE)
|
||||||
|
while IFS='|' read -r key value field label; do
|
||||||
|
input=""
|
||||||
|
for line in "${order[@]}"; do
|
||||||
|
if [[ $line != "$key" ]]; then
|
||||||
|
input+="${answer[$line]}"$'\n'
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
collect_with "$key=$value" "$input"
|
||||||
|
assert_status "$key preset" 0 "$STATUS"
|
||||||
|
assert_contains "$key value used" "$OUT" "$field"
|
||||||
|
assert_not_contains "$key not asked" "$ERR" "$label"
|
||||||
|
assert_contains "other details still asked" "$OUT" "asked"
|
||||||
|
done <<'EOF'
|
||||||
|
PROJECT_NAME|preset-app|name=preset-app|Repository name
|
||||||
|
PROJECT_DESCRIPTION|From the config|description=From the config|Description
|
||||||
|
PROJECT_VISIBILITY|public|visibility=public|Visibility
|
||||||
|
GITEA_OWNER|PresetOrg|gitea_owner=PresetOrg|Gitea owner
|
||||||
|
USE_GITHUB|yes|has_github=1|Also create a GitHub
|
||||||
|
GITHUB_OWNER|preset-gh|github_owner=preset-gh|GitHub owner
|
||||||
|
PROJECT_DIRECTORY|./preset-dir|directory=./preset-dir|Local directory
|
||||||
|
ENABLE_PLAN_GATE|yes|is_plan_gate_enabled=1|Enable the plan gate
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
test_all_keys_set_asks_nothing() {
|
||||||
|
collect_with "$PRESET_ALL" ""
|
||||||
|
assert_status "no input needed" 0 "$STATUS"
|
||||||
|
assert_eq "every value from the config" $'name=preset-app\ndescription=From the config\nvisibility=public\ngitea_owner=PresetOrg\nhas_github=1\ngithub_owner=preset-gh\ndirectory=./preset-dir\nis_plan_gate_enabled=1' "$OUT"
|
||||||
|
assert_eq "no prompt text at all" "" "$ERR"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_absent_keys_are_asked_as_before() {
|
||||||
|
collect_with "PROJECT_NAME=preset-app" $'\n\nTirSystem\nn\n\nn\n'
|
||||||
|
assert_status "mixed" 0 "$STATUS"
|
||||||
|
assert_contains "preset name" "$OUT" "name=preset-app"
|
||||||
|
assert_contains "default directory from the preset name" "$OUT" "directory=./preset-app"
|
||||||
|
assert_contains "other details asked" "$ERR" "Gitea owner"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_values_are_taken_in_any_case() {
|
||||||
|
collect_with $'PROJECT_VISIBILITY=PUBLIC\nUSE_GITHUB=No\nENABLE_PLAN_GATE=YES' $'x-app\n\nTirSystem\n\n'
|
||||||
|
assert_status "case ignored" 0 "$STATUS"
|
||||||
|
assert_contains "visibility" "$OUT" "visibility=public"
|
||||||
|
assert_contains "no GitHub" "$OUT" "has_github=0"
|
||||||
|
assert_contains "plan gate" "$OUT" "is_plan_gate_enabled=1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------- empty and invalid
|
||||||
|
|
||||||
|
test_empty_value_counts_as_set_only_for_the_description() {
|
||||||
|
collect_with "PROJECT_DESCRIPTION=" $'my-app\npublic\nTirSystem\nn\n\nn\n'
|
||||||
|
assert_status "empty description accepted" 0 "$STATUS"
|
||||||
|
assert_contains "description empty" "$OUT" "description="
|
||||||
|
assert_not_contains "description not asked" "$ERR" "Description"
|
||||||
|
local key
|
||||||
|
for key in PROJECT_NAME PROJECT_VISIBILITY GITEA_OWNER USE_GITHUB GITHUB_OWNER \
|
||||||
|
PROJECT_DIRECTORY ENABLE_PLAN_GATE; do
|
||||||
|
printf '%s=\n' "$key" >"$WORK/preset.env"
|
||||||
|
run_lib "" 'parse_env_file "'"$WORK"'/preset.env" CONFIG_KEYS CONFIG
|
||||||
|
validate_project_presets'
|
||||||
|
assert_status "$key empty" 1 "$STATUS"
|
||||||
|
assert_contains "$key named" "$ERR" "$key in"
|
||||||
|
assert_contains "$key says empty" "$ERR" "is empty"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
test_invalid_values_are_refused_naming_the_key() {
|
||||||
|
local key value
|
||||||
|
while IFS='|' read -r key value; do
|
||||||
|
printf '%s=%s\n' "$key" "$value" >"$WORK/preset.env"
|
||||||
|
run_lib "" 'parse_env_file "'"$WORK"'/preset.env" CONFIG_KEYS CONFIG
|
||||||
|
validate_project_presets'
|
||||||
|
assert_status "$key=$value" 1 "$STATUS"
|
||||||
|
assert_contains "$key=$value named" "$ERR" "$key in"
|
||||||
|
done <<'EOF'
|
||||||
|
PROJECT_NAME|bad name
|
||||||
|
PROJECT_NAME|x.git
|
||||||
|
PROJECT_VISIBILITY|internal
|
||||||
|
GITEA_OWNER|-lead
|
||||||
|
USE_GITHUB|maybe
|
||||||
|
USE_GITHUB|1
|
||||||
|
GITHUB_OWNER|octo_user
|
||||||
|
PROJECT_DIRECTORY|-rf
|
||||||
|
ENABLE_PLAN_GATE|true
|
||||||
|
EOF
|
||||||
|
# A description over the limit (350 characters) is refused too.
|
||||||
|
printf 'PROJECT_DESCRIPTION=%s\n' "$(printf 'a%.0s' $(seq 1 351))" >"$WORK/preset.env"
|
||||||
|
run_lib "" 'parse_env_file "'"$WORK"'/preset.env" CONFIG_KEYS CONFIG
|
||||||
|
validate_project_presets'
|
||||||
|
assert_status "long description" 1 "$STATUS"
|
||||||
|
assert_contains "named" "$ERR" "PROJECT_DESCRIPTION in"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_invalid_value_stops_before_any_request_and_never_asks() {
|
||||||
|
setup_hosts
|
||||||
|
printf 'PROJECT_NAME=bad name\n' >>"$WORK/config.env"
|
||||||
|
run_apply ""
|
||||||
|
assert_status "stopped" 1 "$STATUS"
|
||||||
|
assert_contains "key named" "$ERR" "PROJECT_NAME in"
|
||||||
|
assert_not_contains "no value asked instead" "$ERR" "Repository name:"
|
||||||
|
assert_eq "no request made" "" "$(calls)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_new_keys_are_rejected_in_env_and_credentials_in_config() {
|
||||||
|
setup_hosts
|
||||||
|
printf 'PROJECT_NAME=my-app\n' >>"$WORK/.env"
|
||||||
|
run_dry ""
|
||||||
|
assert_status ".env with a project key" 1 "$STATUS"
|
||||||
|
assert_contains "unknown in .env" "$ERR" "unknown key 'PROJECT_NAME'"
|
||||||
|
setup_hosts
|
||||||
|
printf 'GITEA_TOKEN=abcdefgh12345\n' >>"$WORK/config.env"
|
||||||
|
run_dry ""
|
||||||
|
assert_status "config.env with a credential" 1 "$STATUS"
|
||||||
|
assert_contains "credential refused" "$ERR" "is a credential"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- GitHub
|
||||||
|
|
||||||
|
test_use_github_no_skips_the_owner_and_warns_about_a_stray_one() {
|
||||||
|
collect_with $'USE_GITHUB=no\nGITHUB_OWNER=acme-org' $'my-app\n\n\nTirSystem\n\nn\n'
|
||||||
|
assert_status "GitHub off" 0 "$STATUS"
|
||||||
|
assert_contains "no GitHub" "$OUT" "has_github=0"
|
||||||
|
assert_contains "no owner" "$OUT" "github_owner="
|
||||||
|
assert_not_contains "owner not asked" "$ERR" "GitHub owner ("
|
||||||
|
assert_contains "ignored with a warning" "$ERR" "GITHUB_OWNER in"
|
||||||
|
assert_contains "says why" "$ERR" "ignored because GitHub is not used"
|
||||||
|
collect_with "USE_GITHUB=no" $'my-app\n\n\nTirSystem\n\nn\n'
|
||||||
|
assert_not_contains "no warning without the key" "$ERR" "ignored"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_github_owner_preset_is_used_when_github_is_asked_for() {
|
||||||
|
collect_with "GITHUB_OWNER=preset-gh" $'my-app\n\n\nTirSystem\ny\n\nn\n'
|
||||||
|
assert_contains "owner from the config" "$OUT" "github_owner=preset-gh"
|
||||||
|
assert_not_contains "owner not asked" "$ERR" "GitHub owner ("
|
||||||
|
collect_with "GITHUB_OWNER=preset-gh" $'my-app\n\n\nTirSystem\nn\n\nn\n'
|
||||||
|
assert_contains "ignored when answered no" "$ERR" "ignored because GitHub is not used"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_use_github_no_makes_no_github_call() {
|
||||||
|
setup_hosts
|
||||||
|
printf '%s\n' "$PRESET_LIKE_ANSWERS" | sed 's/^USE_GITHUB=.*/USE_GITHUB=no/' >>"$WORK/config.env"
|
||||||
|
run_dry ""
|
||||||
|
assert_status "dry run" 0 "$STATUS"
|
||||||
|
assert_contains "GitHub not used" "$OUT" "GitHub repository : not used"
|
||||||
|
assert_not_contains "no GitHub call" "$(calls)" "api.github.com"
|
||||||
|
assert_not_contains "no GitHub owner asked" "$ERR" "GitHub owner ("
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ the summary
|
||||||
|
|
||||||
|
test_summary_marks_the_values_from_config_env() {
|
||||||
|
setup_hosts
|
||||||
|
printf '%s\n' "$PRESET_LIKE_ANSWERS" >>"$WORK/config.env"
|
||||||
|
run_dry ""
|
||||||
|
assert_status "all preset" 0 "$STATUS"
|
||||||
|
assert_contains "name" "$OUT" "Repository : my-app (from config.env) (private (from config.env))"
|
||||||
|
assert_contains "description" "$OUT" "Description : A test app (from config.env)"
|
||||||
|
assert_contains "Gitea" "$OUT" "/TirSystem/my-app (from config.env)"
|
||||||
|
assert_contains "GitHub" "$OUT" "(AGPL license applied) (from config.env)"
|
||||||
|
assert_contains "directory" "$OUT" "Directory : ./my-app (from config.env)"
|
||||||
|
assert_contains "plan gate" "$OUT" "Plan gate : no (from config.env)"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_summary_marks_nothing_when_everything_is_asked() {
|
||||||
|
setup_hosts
|
||||||
|
run_dry "$ANSWERS_GITHUB"
|
||||||
|
assert_status "all asked" 0 "$STATUS"
|
||||||
|
assert_not_contains "no marker" "$OUT" "(from config.env)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------- the confirmations
|
||||||
|
|
||||||
|
test_with_every_detail_set_only_the_confirmations_are_asked() {
|
||||||
|
setup_hosts
|
||||||
|
run_apply "$ANSWERS_GITHUB"y$'\n'
|
||||||
|
local asked_calls
|
||||||
|
asked_calls="$(calls)"
|
||||||
|
remove_workdir
|
||||||
|
new_workdir
|
||||||
|
setup_hosts
|
||||||
|
printf '%s\n' "$PRESET_LIKE_ANSWERS" >>"$WORK/config.env"
|
||||||
|
run_apply $'y\n'
|
||||||
|
assert_status "run with presets" 0 "$STATUS"
|
||||||
|
assert_eq "same requests as the run that was asked" "$asked_calls" "$(calls)"
|
||||||
|
assert_contains "created" "$OUT" "This is what exists now"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_with_every_detail_set_create_now_is_still_asked_and_defaults_to_no() {
|
||||||
|
setup_hosts
|
||||||
|
printf '%s\n' "$PRESET_LIKE_ANSWERS" >>"$WORK/config.env"
|
||||||
|
run_apply $'n\n'
|
||||||
|
assert_status "declined" 0 "$STATUS"
|
||||||
|
assert_contains "says so" "$OUT" "Nothing was created."
|
||||||
|
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||||
|
setup_hosts
|
||||||
|
printf '%s\n' "$PRESET_LIKE_ANSWERS" >>"$WORK/config.env"
|
||||||
|
run_apply $'\n'
|
||||||
|
assert_contains "empty answer means no" "$OUT" "Nothing was created."
|
||||||
|
assert_not_contains "no POST on the default" "$(calls)" "POST"
|
||||||
|
setup_hosts
|
||||||
|
printf '%s\n' "$PRESET_LIKE_ANSWERS" >>"$WORK/config.env"
|
||||||
|
run_apply ""
|
||||||
|
assert_contains "no answer means no" "$OUT" "Nothing was created."
|
||||||
|
assert_not_contains "nothing created without an answer" "$(calls)" "POST"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_with_every_detail_set_an_existing_directory_is_not_replaced() {
|
||||||
|
setup_hosts
|
||||||
|
printf '%s\n' "$PRESET_LIKE_ANSWERS" >>"$WORK/config.env"
|
||||||
|
mkdir -p "$WORK/my-app"
|
||||||
|
printf 'keep\n' >"$WORK/my-app/mine.txt"
|
||||||
|
run_apply $'y\n\n'
|
||||||
|
assert_file_exists "existing file kept" "$WORK/my-app/mine.txt"
|
||||||
|
assert_eq "content kept" "keep" "$(cat "$WORK/my-app/mine.txt")"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_a_quoted_description_may_contain_a_hash() {
|
||||||
|
local answers=$'my-app\npublic\nTirSystem\nn\n\nn\n'
|
||||||
|
collect_with 'PROJECT_DESCRIPTION="Tool for #mirrors"' "$answers"
|
||||||
|
assert_status "quoted" 0 "$STATUS"
|
||||||
|
assert_contains "whole value kept" "$OUT" "description=Tool for #mirrors"
|
||||||
|
# Unquoted, the same text is cut at the comment mark, as documented.
|
||||||
|
collect_with 'PROJECT_DESCRIPTION=Tool for #mirrors' "$answers"
|
||||||
|
assert_contains "cut at the comment" "$OUT" "description=Tool for"
|
||||||
|
assert_not_contains "comment dropped" "$OUT" "mirrors"
|
||||||
|
}
|
||||||
+31
-18
@@ -4,43 +4,47 @@
|
|||||||
# (MIL-001 Go/No-Go criteria 2 to 4). Sourced by run-tests.sh.
|
# (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
|
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
|
||||||
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'
|
|
||||||
|
|
||||||
# listing: all files under the work directory except the test's own captures.
|
# listing: all files under the work directory except the test's own captures.
|
||||||
listing() {
|
listing() {
|
||||||
find "$WORK" -type f ! -name out.txt ! -name err.txt ! -name snippet.sh \
|
find "$WORK" -type f ! -name out.txt ! -name err.txt ! -name snippet.sh \
|
||||||
! -name 'curl.*' | sort
|
! -name 'curl.*' ! -name 'routes*' ! -name 'ssh.args' | sort
|
||||||
}
|
}
|
||||||
|
|
||||||
test_full_run_with_github() {
|
test_full_run_with_github() {
|
||||||
write_fixtures
|
setup_hosts
|
||||||
write_curl_stub
|
|
||||||
local before after
|
local before after
|
||||||
before="$(listing)"
|
before="$(listing)"
|
||||||
run_cli "$ANSWERS_GITHUB" --config "$WORK/config.env" --env "$WORK/.env"
|
run_cli "$ANSWERS_GITHUB" --config "$WORK/config.env" --env "$WORK/.env"
|
||||||
after="$(listing)"
|
after="$(listing)"
|
||||||
assert_status "full run" 0 "$STATUS"
|
assert_status "full run" 0 "$STATUS"
|
||||||
assert_contains "summary" "$OUT" "nothing has been created yet"
|
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 "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 "GitHub link uses the chosen organization" "$OUT" "https://github.com/acme-org/my-app"
|
||||||
assert_contains "AGPL noted" "$OUT" "AGPL license applied"
|
assert_contains "AGPL noted" "$OUT" "AGPL license applied"
|
||||||
assert_contains "credential state" "$OUT" "GITEA_TOKEN set, GITHUB_PAT set"
|
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 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 GitHub token in output" "$OUT$ERR" "$FAKE_GITHUB_PAT"
|
||||||
assert_file_missing "no network call" "$WORK/curl.args"
|
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 "no file created or changed" "$before" "$after"
|
||||||
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
|
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||||
}
|
}
|
||||||
|
|
||||||
test_full_run_without_github() {
|
test_full_run_without_github() {
|
||||||
write_fixtures
|
setup_hosts
|
||||||
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||||
run_cli "$ANSWERS_GITEA_ONLY" --config "$WORK/config.env" --env "$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_status "Gitea-only run needs no GitHub credentials" 0 "$STATUS"
|
||||||
assert_contains "GitHub not used" "$OUT" "GitHub : not used"
|
assert_contains "GitHub not used" "$OUT" "GitHub : not used"
|
||||||
assert_not_contains "no AGPL line" "$OUT" "AGPL"
|
assert_not_contains "no AGPL line" "$OUT" "AGPL"
|
||||||
assert_contains "GITHUB_PAT not set" "$OUT" "GITHUB_PAT not set"
|
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() {
|
test_github_chosen_without_credentials_fails() {
|
||||||
@@ -99,11 +103,11 @@ test_usage_errors() {
|
|||||||
assert_contains "help shows exit codes" "$OUT" "Exit codes"
|
assert_contains "help shows exit codes" "$OUT" "Exit codes"
|
||||||
run_cli "" --version
|
run_cli "" --version
|
||||||
assert_status "version" 0 "$STATUS"
|
assert_status "version" 0 "$STATUS"
|
||||||
assert_contains "version output" "$OUT" "RepoFoundry 0.1.0"
|
assert_contains "version output" "$OUT" "RepoFoundry 0.3.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
test_warns_when_env_is_not_ignored_by_git() {
|
test_warns_when_env_is_not_ignored_by_git() {
|
||||||
write_fixtures
|
setup_hosts
|
||||||
git init -q "$WORK/repo"
|
git init -q "$WORK/repo"
|
||||||
cp "$WORK/.env" "$WORK/repo/.env"
|
cp "$WORK/.env" "$WORK/repo/.env"
|
||||||
cp "$WORK/config.env" "$WORK/repo/config.env"
|
cp "$WORK/config.env" "$WORK/repo/config.env"
|
||||||
@@ -117,12 +121,20 @@ test_warns_when_env_is_not_ignored_by_git() {
|
|||||||
|
|
||||||
test_script_uses_no_unsafe_constructs() {
|
test_script_uses_no_unsafe_constructs() {
|
||||||
local code
|
local code
|
||||||
code="$(grep -vE '^[[:space:]]*#' "$SCRIPT")"
|
code="$(cat "$SCRIPT" "$SRC_DIR"/lib/*.sh | grep -vE '^[[:space:]]*#')"
|
||||||
assert_not_contains "no rm -rf" "$code" "rm -rf"
|
assert_not_contains "no rm -rf" "$code" "rm -rf"
|
||||||
assert_not_contains "no rm -r" "$code" "rm -r "
|
assert_not_contains "no rm -r" "$code" "rm -r "
|
||||||
assert_not_contains "no eval" "$code" "eval "
|
assert_not_contains "no eval" "$code" "eval "
|
||||||
assert_not_contains "no source" "$code" "source "
|
|
||||||
assert_not_contains "no dot-source" "$code" $'\n. '
|
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
|
check
|
||||||
if grep -Eq '^[[:space:]]*set -[A-Za-z]*x' <<<"$code"; then
|
if grep -Eq '^[[:space:]]*set -[A-Za-z]*x' <<<"$code"; then
|
||||||
fail "the script turns tracing on"
|
fail "the script turns tracing on"
|
||||||
@@ -133,9 +145,9 @@ test_script_uses_no_unsafe_constructs() {
|
|||||||
test_tracing_does_not_leak_secrets() {
|
test_tracing_does_not_leak_secrets() {
|
||||||
# bash -x would print every assignment and command, secrets included, so
|
# bash -x would print every assignment and command, secrets included, so
|
||||||
# the script switches tracing off and says so.
|
# the script switches tracing off and says so.
|
||||||
write_fixtures
|
setup_hosts
|
||||||
STATUS=0
|
STATUS=0
|
||||||
PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" -x "$SCRIPT" \
|
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" -x "$SCRIPT" \
|
||||||
--config "$WORK/config.env" --env "$WORK/.env" <<<"$ANSWERS_GITHUB" \
|
--config "$WORK/config.env" --env "$WORK/.env" <<<"$ANSWERS_GITHUB" \
|
||||||
>"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
|
>"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
|
||||||
assert_status "run under bash -x" 0 "$STATUS"
|
assert_status "run under bash -x" 0 "$STATUS"
|
||||||
@@ -157,7 +169,7 @@ test_termination_removes_temp_files() {
|
|||||||
if ! mkfifo "$WORK/in" 2>/dev/null; then
|
if ! mkfifo "$WORK/in" 2>/dev/null; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" "$SCRIPT" \
|
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" "$SCRIPT" \
|
||||||
--config "$WORK/config.env" --env "$WORK/.env" <"$WORK/in" \
|
--config "$WORK/config.env" --env "$WORK/.env" <"$WORK/in" \
|
||||||
>/dev/null 2>&1 &
|
>/dev/null 2>&1 &
|
||||||
local pid=$! tries=0
|
local pid=$! tries=0
|
||||||
@@ -182,19 +194,20 @@ test_script_lives_in_src() {
|
|||||||
|
|
||||||
test_default_files_are_in_the_project_root() {
|
test_default_files_are_in_the_project_root() {
|
||||||
# A project copy: script in src/, config.env and .env one level up.
|
# A project copy: script in src/, config.env and .env one level up.
|
||||||
write_fixtures
|
setup_hosts
|
||||||
mkdir -p "$WORK/project/src"
|
mkdir -p "$WORK/project/src"
|
||||||
cp "$SCRIPT" "$WORK/project/src/create-project.sh"
|
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/config.env" "$WORK/project/config.env"
|
||||||
cp "$WORK/.env" "$WORK/project/.env"
|
cp "$WORK/.env" "$WORK/project/.env"
|
||||||
STATUS=0
|
STATUS=0
|
||||||
PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
|
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=$?
|
<<<"$ANSWERS_GITHUB" >"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
|
||||||
assert_status "run with the default files" 0 "$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"
|
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.
|
# Run from another directory: the defaults follow the script, not the cwd.
|
||||||
STATUS=0
|
STATUS=0
|
||||||
(cd "$WORK" && PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
|
(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=$?
|
<<<"$ANSWERS_GITEA_ONLY" >"$WORK/out.txt" 2>"$WORK/err.txt") || STATUS=$?
|
||||||
assert_status "run from another directory" 0 "$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