Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 41 additions & 16 deletions cmd/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,55 @@ var (
genOutput string
genName string
genTokenEnv string
genInclude []string
genExclude []string
genBase string
)

var generateCmd = &cobra.Command{
Use: "generate <pr-url>",
Short: "Generate a loom module from a GitHub PR or GitLab MR",
Long: `Generate a reusable loom module by analyzing the file changes in an existing
pull request or merge request. Added files become templates, modified YAML files
become strategic merge patches, and concrete values you specify with -p are
replaced with template parameters.
Use: "generate <ref> [<ref>...]",
Short: "Generate a loom module from PRs/MRs, commits, or local files",
Long: `Generate a reusable loom module by analyzing file changes from one or more
sources. Added files become templates, modified YAML files become strategic
merge patches, and concrete values you specify with -p are replaced with
template parameters.

By default, files are generated into the current directory. Use -o to specify
a different output directory.
When multiple references are given they are composed in order (oldest first)
into a single net changeset — useful when the desired state was reached over
several PRs or follow-up commits.

Supported references:
https://github.com/owner/repo/pull/123
https://gitlab.com/group/repo/-/merge_requests/123
github:owner/repo#123
gitlab:group/repo!123`,
Args: cobra.ExactArgs(1),
PR/MR (provider API):
https://github.com/owner/repo/pull/123
https://gitlab.com/group/repo/-/merge_requests/123
github:owner/repo#123
gitlab:group/repo!123
Commit or commit range (git-native, any host):
github:owner/repo@abc1234
github:owner/repo@abc1234...def5678
git@bitbucket.org:owner/repo.git@abc1234...def5678
https://github.com/owner/repo/commit/abc1234
./checkout@abc1234...def5678
Snapshot of files (with --include, optionally --base):
./checkout /abs/path file:relative/path (local working tree)
snapshot:github:owner/repo@v1.2.3 (remote, committed tree at ref)
snapshot:git@host:owner/repo.git (remote, default branch)
snapshot:./checkout@release-2024 (local, committed tree at ref)

By default, files are generated into the current directory. Use -o to specify
a different output directory.`,
Args: cobra.MinimumNArgs(1),
RunE: runGenerate,
}

func init() {
generateCmd.Flags().StringArrayVarP(&genParams, "param", "p", nil, "Value to parameterize: key=value (can be repeated)")
generateCmd.Flags().StringVarP(&genOutput, "output", "o", "", "Output directory (default: current directory)")
generateCmd.Flags().StringVarP(&genName, "name", "n", "", "Module name (default: derived from PR title)")
generateCmd.Flags().StringVar(&genTokenEnv, "token-env", "", "Env var holding the API token (default: GITHUB_TOKEN or GITLAB_TOKEN)")
generateCmd.Flags().StringVarP(&genName, "name", "n", "", "Module name (default: derived from PR title or commit subject)")
generateCmd.Flags().StringVar(&genTokenEnv, "token-env", "", "Env var holding the API token for PR/MR sources (default: GITHUB_TOKEN or GITLAB_TOKEN)")
generateCmd.Flags().StringArrayVar(&genInclude, "include", nil, "Glob of files to capture from a snapshot source (can be repeated; ** matches directories)")
generateCmd.Flags().StringArrayVar(&genExclude, "exclude", nil, "Glob of files to skip from a snapshot source (can be repeated)")
generateCmd.Flags().StringVar(&genBase, "base", "", "Git ref to diff a snapshot source against (default: capture files as-is)")
rootCmd.AddCommand(generateCmd)
}

Expand All @@ -49,11 +71,14 @@ func runGenerate(cmd *cobra.Command, args []string) error {
}

opts := generate.Options{
Ref: args[0],
Refs: args,
Params: paramMap,
OutputDir: genOutput,
ModuleName: genName,
TokenEnv: genTokenEnv,
Include: genInclude,
Exclude: genExclude,
Base: genBase,
}

return generate.Run(cmd.Context(), opts, logger)
Expand Down
103 changes: 86 additions & 17 deletions docs/guide/generate.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Generate

`loom generate` reverse-engineers a reusable module from an existing GitHub PR or GitLab MR. This is the fastest way to turn a manual change you've already made into repeatable automation.
`loom generate` reverse-engineers a reusable module from changes that already exist — a GitHub PR or GitLab MR, specific commits, or the current state of files in a repository. This is the fastest way to turn a manual change you've already made into repeatable automation.

## How It Works

Expand All @@ -12,7 +12,7 @@ loom generate https://github.com/myorg/gitops-repo/pull/42 \
-p namespace=fintech
```

Loom fetches the PR diff and produces a ready-to-use module:
Loom fetches the changes and produces a ready-to-use module:

- **Added files** become templates with <code v-pre>{{ .paramName }}</code> replacing the concrete values, including in file and folder names.
- **Modified YAML files** become strategic merge patches under `__functions/patches/`.
Expand All @@ -21,6 +21,76 @@ Loom fetches the PR diff and produces a ready-to-use module:

The generated `loom.yaml` declares all parameters as required and wires up the operations in order.

## Sources

Real changes don't always live in a single tidy PR. Generate accepts three kinds of sources, and they can be combined:

### Pull Requests / Merge Requests

```bash
loom generate https://github.com/myorg/gitops-repo/pull/42 ...
loom generate github:myorg/gitops-repo#42 ...
loom generate gitlab:mygroup/gitops-repo!42 ...
```

Fetched via the provider API (see [Authentication](#authentication)). PR title, body, and branches carry over into the generated `commitPush` and `pr` operations.

### Commits and Commit Ranges

Sometimes the change is a commit or a range of commits, not a PR. Commit sources are **git-native** — no provider API, no token. They work against any git host (GitHub, GitLab, Bitbucket, Gitea, bare SSH remotes) using your existing git credentials:

```bash
# one commit
loom generate github:myorg/gitops-repo@9f3ab12 ...

# a range: everything from base (exclusive) to head (inclusive)
loom generate 'github:myorg/gitops-repo@a1b2c3d...f6e5d4c' ...

# any git host — the repo part is just a git URL
loom generate 'git@bitbucket.org:myorg/gitops-repo.git@a1b2c3d...f6e5d4c' ...

# a checkout you already have (no clone)
loom generate './gitops-checkout@a1b2c3d...f6e5d4c' ...
```

Commit and compare URLs from GitHub/GitLab also work as shorthand (`https://github.com/o/r/commit/abc1234`). Remote repos are cloned bare with lazy blob fetching, so cost scales with the changed files, not the repository size.

### Snapshots (current state of files)

Sometimes there's no clean history at all — the files just *are* the desired state. A snapshot source captures files matched by `--include` globs:

```bash
# working tree of a local checkout, including uncommitted files
loom generate ./gitops-checkout --include 'services/payments/**' -n onboard-payments ...

# committed tree of a remote repo at a tag — no local checkout needed
loom generate 'snapshot:github:myorg/gitops-repo@v1.2.3' --include 'services/payments/**' -n onboard-payments ...

# diff the current state against a baseline instead of capturing everything
loom generate ./gitops-checkout --base main --include 'argocd/**' -n update-argo ...
```

Two orthogonal choices:

- **What is captured.** A bare local path captures the **working tree** — including uncommitted and untracked files (the only form that can). `snapshot:<repo>[@<ref>]` captures the **committed tree** at a ref (default branch if omitted), locally or remotely.
- **How it becomes a module.** Without `--base`, every matched file becomes a template — a *stamp-out* module. With `--base <git-ref>`, the captured state is diffed against that baseline, so modified YAML becomes strategic merge patches — a *transform* module.

Snapshots have no title to derive a name from, so `-n` is required. One snapshot source per invocation; use repeated `--include` flags to capture multiple areas.

## Combining Sources

If the desired state took a few attempts to reach, pass all of them **oldest first** — Loom composes them into one net changeset:

```bash
# the original PR plus two follow-up fixes
loom generate github:myorg/gitops#42 github:myorg/gitops#47 github:myorg/gitops#51 ...

# a PR plus the fix commit that landed after it
loom generate github:myorg/gitops#42 github:myorg/gitops@9f3ab12 ...
```

Composition works per file: a file added in one PR and modified in the next becomes a single template with the final content; add-then-delete disappears; a PR and its revert cancel out (and error, since nothing is left). All sources must reference the same repository. Metadata (title, branches) comes from the last source that has it — the one closest to the desired state.

## Example

Given a PR that added these files for the "payments" service in the "fintech" namespace:
Expand Down Expand Up @@ -60,35 +130,34 @@ loom run ./onboard-service -p serviceName=billing -p namespace=platform

Same structure, different parameters. What took a PR now takes a command.

## Supported Providers
## Git Operations in the Generated Module

Both GitHub and GitLab are supported:
PR sources carry full metadata, so the module gets a `target`, a `commitPush`, and a `pr` operation. Sources with less metadata degrade gracefully:

```
https://github.com/owner/repo/pull/123
https://gitlab.com/group/repo/-/merge_requests/123
github:owner/repo#123
gitlab:group/repo!123
```
- No head branch (commits, snapshots) → the feature branch is synthesized as `loom/<module-name>`.
- No recognizable provider (e.g. Bitbucket) → the `pr` operation is omitted with a warning; `commitPush` remains.
- No repository URL at all (a directory with no git remote) → `target` is omitted; fill it in before running the module.

## Authentication

Uses `GITHUB_TOKEN` / `GITLAB_TOKEN` environment variables by default. If the token is not set but the `gh` or `glab` CLI is installed and authenticated, Loom falls back to it automatically.
Only PR/MR sources talk to a provider API. They use `GITHUB_TOKEN` / `GITLAB_TOKEN` environment variables by default; if the token is not set but the `gh` or `glab` CLI is installed and authenticated, Loom falls back to it automatically.

Commit and snapshot sources go through git directly and use whatever credentials git already has (SSH keys, credential helpers).

## Options

| Flag | Description |
|------|-------------|
| `-p, --param key=value` | Concrete value to parameterize (repeatable) |
| `-o, --output dir` | Output directory (default: current directory) |
| `-n, --name name` | Module name (default: derived from PR title) |
| `--token-env VAR` | Env var holding the API token |
| `--exclude-git-ops` | Skip generating `target`, `commitPush`, and `pr` operations |
| `-n, --name name` | Module name (default: derived from PR title or commit subject) |
| `--token-env VAR` | Env var holding the API token (PR/MR sources only) |
| `--include glob` | Files to capture from a snapshot source (repeatable; `**` matches directories) |
| `--exclude glob` | Files to skip from a snapshot source (repeatable) |
| `--base git-ref` | Baseline to diff a snapshot source against |

By default, the generated module is written to the current directory. Use `-o` to write to a different directory:

```bash
loom generate <pr-url> -o ./my-module
loom generate <ref> -o ./my-module
```

Use `--exclude-git-ops` when you only want the template/patch operations and will handle git operations separately.
73 changes: 63 additions & 10 deletions docs/reference/cli-generate.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# loom generate

Generate a reusable loom module from an existing GitHub PR or GitLab MR. This is the fastest way to turn a manual change you've already made into repeatable automation.
Generate a reusable loom module from existing changes: GitHub PRs / GitLab MRs, commits or commit ranges, or a snapshot of files in a repository. Multiple references compose into one net changeset.

```
loom generate <pr-url> [flags]
loom generate <ref> [<ref>...] [flags]
```

## Flags
Expand All @@ -12,30 +12,83 @@ loom generate <pr-url> [flags]
|------|-------------|
| `-p, --param key=value` | Concrete value to parameterize (repeatable). Every occurrence of the literal value is replaced with a template expression. |
| `-o, --output dir` | Output directory for the generated module (default: current directory) |
| `-n, --name name` | Module name (default: derived from PR title) |
| `--token-env VAR` | Env var holding the API token (default: `GITHUB_TOKEN` or `GITLAB_TOKEN`) |
| `--exclude-git-ops` | Skip generating `target`, `commitPush`, and `pr` operations |
| `-n, --name name` | Module name (default: derived from PR title or commit subject; required for snapshot-only sources) |
| `--token-env VAR` | Env var holding the API token for PR/MR sources (default: `GITHUB_TOKEN` or `GITLAB_TOKEN`) |
| `--include glob` | Files to capture from a snapshot source (repeatable; `**` matches any number of directories). Required with a snapshot ref. |
| `--exclude glob` | Files to skip from a snapshot source (repeatable) |
| `--base git-ref` | Baseline to diff a snapshot source against (default: capture matched files as-is) |

## Supported References

### PR / MR (provider API)

```
https://github.com/owner/repo/pull/123
https://gitlab.com/group/repo/-/merge_requests/123
github:owner/repo#123
gitlab:group/repo!123
```

Self-hosted instances work with full URLs; use the `github:` / `gitlab:` prefix when the URL pattern is ambiguous.

### Commit or commit range (git-native, any host)

```
github:owner/repo@abc1234 # one commit (diff vs. its first parent)
github:owner/repo@abc1234...def5678 # range: base (exclusive) to head (inclusive)
git@bitbucket.org:owner/repo.git@abc1234...def5678 # any git URL as the repo part
./checkout@abc1234...def5678 # local checkout, no clone
https://github.com/owner/repo/commit/abc1234 # commit URL shorthand
https://gitlab.com/group/repo/-/compare/a...b # compare URL shorthand
```

The rev suffix is split on the **last** `@` and must be a hex SHA (7–40 chars) or tag name, or a `...` range of them. No API or token involved: remote repos are cloned bare with lazy blob fetching and your git credentials.

### Snapshot (state of files)

```
./checkout # working tree, incl. uncommitted/untracked files
/abs/path file:relative/path # same, alternative spellings
snapshot:github:owner/repo@v1.2.3 # committed tree of a remote repo at a ref
snapshot:git@host:owner/repo.git # committed tree at the default branch
snapshot:./checkout@release-2024 # committed tree of a local repo at a ref
```

Requires at least one `--include`. At most one snapshot ref per invocation. Without `--base` every matched file becomes a template; with `--base` the captured state is diffed against that ref, producing patches for modified YAML. Local paths with `@<ref>` (and `--base`) must point at the repository root. Symlinks and submodules are skipped with a warning.

## Multiple Sources

Pass references **oldest first**; they are composed in the order given into a single net changeset:

```bash
loom generate github:org/gitops#42 github:org/gitops#47 github:org/gitops@9f3ab12
```

- Per file: old content from the first source touching it, new content from the last. Add-then-delete drops out; delete-then-re-add nets to a modification; rename chains collapse.
- All sources must reference the same repository (HTTPS/SSH spellings are equivalent).
- Metadata (title, body, branches) comes from the last source that has it.
- If everything cancels out (e.g. a PR and its revert), generation fails with `no net file changes after composing sources`.

## Generated Git Operations

| Source metadata | Result |
|-----------------|--------|
| Full PR metadata | `target` + `commitPush` + `pr` operations, as before |
| No head branch (commit/snapshot) | Feature branch synthesized as `loom/<module-name>` |
| No recognizable provider | `pr` operation omitted (warning); `commitPush` kept |
| No repository URL | `target` omitted (warning); fill in manually before running |

## Output

By default, the generated module is written to the current directory. Use `-o` to write to a different directory:

```bash
loom generate <pr-url> -o ./my-module
loom generate <ref> -o ./my-module
```

## How It Works

Loom fetches the PR diff and produces a ready-to-use module:
Loom fetches the changes from each source and produces a ready-to-use module:

- **Added files** become templates with <code v-pre>{{ .paramName }}</code> replacing the concrete values, including in file and folder names.
- **Modified YAML files** become strategic merge patches under `__functions/patches/`.
Expand All @@ -46,10 +99,10 @@ The generated `loom.yaml` declares all parameters as required and wires up the o

## Example

You onboarded a service called "payments" via a PR. Now you want to make that repeatable:
You onboarded a service called "payments" via a PR, then fixed it in a follow-up commit. Make the combined result repeatable:

```bash
loom generate https://github.com/myorg/gitops-repo/pull/42 \
loom generate https://github.com/myorg/gitops-repo/pull/42 github:myorg/gitops-repo@9f3ab12 \
-p serviceName=payments \
-p namespace=fintech
```
Expand All @@ -62,4 +115,4 @@ loom run ./onboard-service -p serviceName=billing -p namespace=platform

## Authentication

Uses `GITHUB_TOKEN` / `GITLAB_TOKEN` environment variables by default. If the token is not set but the `gh` or `glab` CLI is installed and authenticated, Loom falls back to it automatically.
Only PR/MR sources use a provider API: `GITHUB_TOKEN` / `GITLAB_TOKEN` by default, with automatic fallback to an authenticated `gh` / `glab` CLI. Commit and snapshot sources are git-native and use your existing git credentials (SSH keys, credential helpers).
Loading
Loading