wiki_promoter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +176 -0
- data/action/action.yml +100 -0
- data/exe/wiki-promoter +84 -0
- data/lib/wiki_promoter/migrator.rb +315 -0
- data/lib/wiki_promoter/publisher.rb +368 -0
- data/lib/wiki_promoter/tasks.rb +38 -0
- data/lib/wiki_promoter/version.rb +5 -0
- data/lib/wiki_promoter.rb +23 -0
- metadata +112 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 49ad9cfb63d2cfd271010eca6a921eec3e92e093cd8de1c261b2c7a708ca3216
|
|
4
|
+
data.tar.gz: c19692e580ab14ac6f1f7d8da21dae691dbafd133a801c2153f7592c306f3935
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1b1389319179165e05c710354f22ed36ba3ed16232958e9fc24fafac8e39fcad7028a7125ab67498ab6c2e55269b3f913ca50866d645441d7516155d84a7265e
|
|
7
|
+
data.tar.gz: f26d49102ad80218c57527bceb9345a31b5a26ae57404ac75bccbb5736a32d526ac73a7e7657bf13439c36643171494ad4754e41d8b65fc5c023e8607019af42
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-07-05
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial release of `wiki_promoter` gem
|
|
13
|
+
- Core `Migrator` class for flattening and relinking markdown research trees
|
|
14
|
+
- Hierarchical page naming with alphabetic, roman, and numeric numbering
|
|
15
|
+
- Link rewriting to reference new wiki page names
|
|
16
|
+
- Collision detection for duplicate page names
|
|
17
|
+
- `repoint_references` method for updating roadmap references
|
|
18
|
+
- CLI executable: `wiki-promoter migrate`
|
|
19
|
+
- Rake tasks: `wiki:migrate` and `wiki:publish`
|
|
20
|
+
- Portable publisher implementation for wiki sync, Home index updates, roadmap repointing, and source cleanup
|
|
21
|
+
- Composite action wired through the shared CLI publish workflow
|
|
22
|
+
- Comprehensive unit and edge-case tests
|
|
23
|
+
- Composite GitHub Action wrapper in `action/action.yml`
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GitHub Copilot
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# GitHub Wiki Research PR
|
|
2
|
+
|
|
3
|
+
A pure-Ruby gem for mechanized promotion of markdown research/planning documents from a `docs/` hierarchy into a GitHub Wiki. Includes a CLI tool and composite GitHub Action.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Hierarchical page naming**: Automatically generates numbered, cased wiki page titles from directory structure
|
|
8
|
+
- **Link rewriting**: Rewrites relative links to reference new wiki page names
|
|
9
|
+
- **Collision detection**: Detects and reports when multiple source files would flatten to the same wiki page
|
|
10
|
+
- **Roadmap reference repointing**: Targeted literal replacement for updating roadmap doc references after source deletion
|
|
11
|
+
- **Pure Ruby**: No native extensions, no external dependencies
|
|
12
|
+
- **CLI & Rake tasks**: Exposes both a command-line tool and reusable Rake tasks
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Add to your `Gemfile`:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem "wiki_promoter"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Then run:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
### CLI
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
wiki-promoter migrate --output-dir /tmp/wiki docs/77-your-research-tree
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This flattens and relinks the docs tree into GitHub Wiki format, outputting to the specified directory.
|
|
37
|
+
|
|
38
|
+
### Rake Tasks
|
|
39
|
+
|
|
40
|
+
Add to your `Rakefile`:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
require "wiki_promoter/tasks"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then use:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bundle exec rake wiki:migrate[docs/77-your-tree]
|
|
50
|
+
bundle exec rake wiki:publish[docs/77-your-tree]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`wiki:publish` expects `WIKI_DEPLOY_TOKEN` and `WIKI_REPOSITORY` (for example,
|
|
54
|
+
`your-org/your-repo.wiki`). Optional environment variables include
|
|
55
|
+
`ROADMAP_PATH`, `UPDATE_HOME_PAGE`, `SOURCE_REPOSITORY`, `WIKI_FORCE`,
|
|
56
|
+
`WIKI_BRANCH`, `WIKI_OUTPUT_DIR`, and `WIKI_CHECKOUT_DIR`.
|
|
57
|
+
|
|
58
|
+
`WIKI_DEPLOY_TOKEN` must be a classic GitHub PAT with `repo` scope — the
|
|
59
|
+
default `GITHUB_TOKEN` cannot push to a repository's wiki. See
|
|
60
|
+
[Local secrets](#local-secrets) for running this locally via `.env.local`.
|
|
61
|
+
|
|
62
|
+
### Ruby API
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
require "wiki_promoter"
|
|
66
|
+
|
|
67
|
+
migrator = WikiPromoter::Migrator.new("docs/77-your-tree")
|
|
68
|
+
pages = migrator.pages # => {"77. Title.md" => "content", ...}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
### Setup
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
bin/setup
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Local secrets
|
|
80
|
+
|
|
81
|
+
Copy `.env.sample` to `.env.local` (gitignored) and fill in real values:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
cp .env.sample .env.local
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`bundle exec rake` loads `.env.local` automatically via `dotenv`, so tasks
|
|
88
|
+
like `wiki:publish` can pick up `WIKI_DEPLOY_TOKEN` without exporting it by
|
|
89
|
+
hand. See `.env.sample` for what each variable is for.
|
|
90
|
+
|
|
91
|
+
### Running Tests
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
bundle exec rake test
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Running Linter
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
bundle exec standardrb
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Fixing Linter Issues
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
bundle exec standardrb --fix
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Releasing
|
|
110
|
+
|
|
111
|
+
Releases publish to [rubygems.org](https://rubygems.org) via
|
|
112
|
+
`.github/workflows/release.yml`, which runs on any pushed `v*` tag.
|
|
113
|
+
|
|
114
|
+
**One-time setup:**
|
|
115
|
+
|
|
116
|
+
1. Create (or use an existing) [rubygems.org](https://rubygems.org) account.
|
|
117
|
+
2. Under [Profile → API Keys](https://rubygems.org/profile/edit), create a key
|
|
118
|
+
scoped to "Push rubygem" (optionally restricted to the `wiki_promoter`
|
|
119
|
+
gem).
|
|
120
|
+
3. Add it as a GitHub Actions repo secret named `RUBYGEMS_API_KEY`:
|
|
121
|
+
`gh secret set RUBYGEMS_API_KEY --repo cpb/wiki_promoter`, or via
|
|
122
|
+
**Settings → Secrets and variables → Actions**.
|
|
123
|
+
|
|
124
|
+
**Cutting a release:**
|
|
125
|
+
|
|
126
|
+
1. Bump the version in `lib/wiki_promoter/version.rb`.
|
|
127
|
+
2. Update `CHANGELOG.md`.
|
|
128
|
+
3. Commit, then tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
|
129
|
+
4. The `release` workflow builds the gem and runs `gem push` using the
|
|
130
|
+
`RUBYGEMS_API_KEY` secret — no manual `gem push` needed.
|
|
131
|
+
|
|
132
|
+
## Docs Structure Conventions
|
|
133
|
+
|
|
134
|
+
The gem expects `docs/` trees to follow this naming pattern:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
docs/
|
|
138
|
+
├── 77-issue-slug/ # Issue number required, slug auto-becomes entry page name
|
|
139
|
+
│ ├── README.md # Becomes "77. ..."
|
|
140
|
+
│ ├── research/
|
|
141
|
+
│ │ ├── README.md # Becomes "77.a. ..."
|
|
142
|
+
│ │ ├── data.md # Becomes "77.a.i. ..."
|
|
143
|
+
│ │ └── ...
|
|
144
|
+
│ ├── analysis/
|
|
145
|
+
│ │ ├── README.md # Becomes "77.b. ..."
|
|
146
|
+
│ │ └── ...
|
|
147
|
+
│ └── ...
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Numbering rules:**
|
|
151
|
+
- Root level: Issue number (e.g., `77`)
|
|
152
|
+
- Subdirectories: Alphabetic (`a`, `b`, `c`, ...)
|
|
153
|
+
- Files in each dir: Numeric (`1`, `2`, `3`, ...) or Roman (`i`, `ii`, `iii`, ...) depending on depth
|
|
154
|
+
|
|
155
|
+
**Page titles:**
|
|
156
|
+
- Extracted from H1 markdown headings (`# Title`)
|
|
157
|
+
- Unsafe characters (`*`, `:`, `?`, etc.) stripped
|
|
158
|
+
- Consecutive spaces collapsed
|
|
159
|
+
- Long titles truncated to 120 characters with `…`
|
|
160
|
+
|
|
161
|
+
## GitHub Action
|
|
162
|
+
|
|
163
|
+
The `action/` directory defines a composite GitHub Action that runs the migration workflow end-to-end: flatten pages, sync the wiki, optionally update `Home.md`, repoint roadmap references, remove the source docs tree, and push the cleanup commit back to the branch.
|
|
164
|
+
|
|
165
|
+
```yaml
|
|
166
|
+
- uses: your-org/wiki_promoter/action@v1
|
|
167
|
+
with:
|
|
168
|
+
docs_path: docs/77-spike-slug
|
|
169
|
+
wiki_deploy_token: ${{ secrets.WIKI_DEPLOY_TOKEN }}
|
|
170
|
+
wiki_repository: your-org/your-repo.wiki
|
|
171
|
+
roadmap_path: docs/2026-07-01-roadmap.md
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT
|
data/action/action.yml
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Composite action that flattens a docs/<issue>-slug research tree, pushes it
|
|
2
|
+
# to the repository's wiki, updates Home.md, repoints the roadmap, and removes
|
|
3
|
+
# the source tree from the PR branch.
|
|
4
|
+
#
|
|
5
|
+
# NOTE: this action performs its OWN checkout of the calling repository into
|
|
6
|
+
# ${{ github.workspace }} (it needs the docs tree and push access to the PR
|
|
7
|
+
# branch). Do not rely on a checkout your workflow ran earlier — this step
|
|
8
|
+
# will reset the workspace to github.ref.
|
|
9
|
+
name: "Migrate Docs to Wiki"
|
|
10
|
+
description: "Flatten, migrate, and sync GitHub Markdown research trees to GitHub Wiki"
|
|
11
|
+
|
|
12
|
+
inputs:
|
|
13
|
+
docs_path:
|
|
14
|
+
description: "Path to the docs/<issue>-<slug> research tree to migrate (required)"
|
|
15
|
+
required: true
|
|
16
|
+
wiki_deploy_token:
|
|
17
|
+
description: "Classic PAT with 'repo' scope for wiki read/write access (required)"
|
|
18
|
+
required: true
|
|
19
|
+
wiki_repository:
|
|
20
|
+
description: "Target wiki repository, e.g. org/repo.wiki. Defaults to the current repository's wiki."
|
|
21
|
+
required: false
|
|
22
|
+
roadmap_path:
|
|
23
|
+
description: "Path to roadmap markdown file for reference repointing (defaults to docs/2026-07-01-roadmap.md)"
|
|
24
|
+
required: false
|
|
25
|
+
default: "docs/2026-07-01-roadmap.md"
|
|
26
|
+
update_home_page:
|
|
27
|
+
description: "Whether to auto-index the entry page on Home.md (defaults to 'true')"
|
|
28
|
+
required: false
|
|
29
|
+
default: "true"
|
|
30
|
+
entry_page_name:
|
|
31
|
+
description: "Override for the auto-derived wiki entry page name (optional)"
|
|
32
|
+
required: false
|
|
33
|
+
force:
|
|
34
|
+
description: "Whether to force publishing despite unsupported file types or overwriting different wiki pages (defaults to 'false')"
|
|
35
|
+
required: false
|
|
36
|
+
default: "false"
|
|
37
|
+
pr_number:
|
|
38
|
+
description: "PR number this branch belongs to; when set, posts a summary comment linking the migrated wiki page (optional)"
|
|
39
|
+
required: false
|
|
40
|
+
ref:
|
|
41
|
+
description: "Branch name to check out and push the cleanup commit to (defaults to the workflow's ref)"
|
|
42
|
+
required: false
|
|
43
|
+
|
|
44
|
+
runs:
|
|
45
|
+
using: "composite"
|
|
46
|
+
steps:
|
|
47
|
+
- name: "Check out repository"
|
|
48
|
+
uses: "actions/checkout@v4"
|
|
49
|
+
with:
|
|
50
|
+
token: ${{ github.token }}
|
|
51
|
+
ref: ${{ inputs.ref || github.ref }}
|
|
52
|
+
|
|
53
|
+
- name: "Set up Ruby"
|
|
54
|
+
uses: "ruby/setup-ruby@v1"
|
|
55
|
+
with:
|
|
56
|
+
ruby-version: "3.3"
|
|
57
|
+
|
|
58
|
+
- name: "Publish docs tree"
|
|
59
|
+
id: "publish"
|
|
60
|
+
shell: "bash"
|
|
61
|
+
env:
|
|
62
|
+
DOCS_PATH: "${{ inputs.docs_path }}"
|
|
63
|
+
WIKI_DEPLOY_TOKEN: "${{ inputs.wiki_deploy_token }}"
|
|
64
|
+
WIKI_REPOSITORY: "${{ inputs.wiki_repository || format('{0}.wiki', github.repository) }}"
|
|
65
|
+
ROADMAP_PATH: "${{ inputs.roadmap_path }}"
|
|
66
|
+
UPDATE_HOME_PAGE: "${{ inputs.update_home_page }}"
|
|
67
|
+
ENTRY_PAGE_NAME: "${{ inputs.entry_page_name }}"
|
|
68
|
+
WIKI_FORCE: "${{ inputs.force }}"
|
|
69
|
+
SOURCE_REPOSITORY: "${{ github.repository }}"
|
|
70
|
+
GITHUB_SERVER_URL: "${{ github.server_url }}"
|
|
71
|
+
GITHUB_REF_NAME: "${{ inputs.ref || github.ref_name }}"
|
|
72
|
+
WIKI_OUTPUT_DIR: "${{ runner.temp }}/wiki-migration"
|
|
73
|
+
WIKI_CHECKOUT_DIR: "${{ runner.temp }}/wiki"
|
|
74
|
+
GIT_USER_NAME: "github-actions[bot]"
|
|
75
|
+
GIT_USER_EMAIL: "github-actions[bot]@users.noreply.github.com"
|
|
76
|
+
# action.yml lives in action/, so the gem's lib/ and exe/ are one level
|
|
77
|
+
# up from github.action_path.
|
|
78
|
+
run: |
|
|
79
|
+
cd "${{ github.workspace }}"
|
|
80
|
+
gem_root="${{ github.action_path }}/.."
|
|
81
|
+
url="$(ruby -I"${gem_root}/lib" -e 'require "wiki_promoter"; name = ENV["ENTRY_PAGE_NAME"].to_s.empty? ? nil : ENV["ENTRY_PAGE_NAME"]; migrator = WikiPromoter::Migrator.new(ENV.fetch("DOCS_PATH"), entry_page_name: name); entry = migrator.entry_wiki_name.tr(" ", "-"); puts "#{ENV.fetch("GITHUB_SERVER_URL")}/#{ENV.fetch("SOURCE_REPOSITORY")}/wiki/#{entry}"')"
|
|
82
|
+
ruby -I"${gem_root}/lib" "${gem_root}/exe/wiki-promoter" publish "${DOCS_PATH}"
|
|
83
|
+
echo "url=${url}" >> "${GITHUB_OUTPUT}"
|
|
84
|
+
|
|
85
|
+
- name: "Comment on the PR"
|
|
86
|
+
if: inputs.pr_number != ''
|
|
87
|
+
shell: "bash"
|
|
88
|
+
env:
|
|
89
|
+
GH_TOKEN: "${{ github.token }}"
|
|
90
|
+
PR_NUMBER: "${{ inputs.pr_number }}"
|
|
91
|
+
DOCS_PATH: "${{ inputs.docs_path }}"
|
|
92
|
+
WIKI_URL: "${{ steps.publish.outputs.url }}"
|
|
93
|
+
run: |
|
|
94
|
+
cd "${{ github.workspace }}"
|
|
95
|
+
gh pr comment "${PR_NUMBER}" --body "Migrated \`${DOCS_PATH}\` to the wiki: ${WIKI_URL}"
|
|
96
|
+
|
|
97
|
+
outputs:
|
|
98
|
+
wiki_url:
|
|
99
|
+
description: "URL to the wiki entry page"
|
|
100
|
+
value: ${{ steps.publish.outputs.url }}
|
data/exe/wiki-promoter
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../lib/wiki_promoter"
|
|
5
|
+
|
|
6
|
+
def print_usage
|
|
7
|
+
puts "Usage:"
|
|
8
|
+
puts " wiki-promoter migrate [--output-dir DIR] [--force] DOCS_PATH"
|
|
9
|
+
puts " wiki-promoter publish [--force] DOCS_PATH"
|
|
10
|
+
puts ""
|
|
11
|
+
puts "Flattens and relinks a docs/ research tree into GitHub Wiki format."
|
|
12
|
+
puts "The publish command also syncs the wiki, updates Home.md, repoints roadmap references, and removes the source tree."
|
|
13
|
+
puts ""
|
|
14
|
+
puts "Arguments:"
|
|
15
|
+
puts " DOCS_PATH Path to docs/<issue>-<slug> tree to migrate"
|
|
16
|
+
puts ""
|
|
17
|
+
puts "Options:"
|
|
18
|
+
puts " --output-dir DIR Output directory (default: tmp/wiki-migration)"
|
|
19
|
+
puts " --force, -f Force publication / migration bypassing validation"
|
|
20
|
+
puts " --help Show this help message"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def main
|
|
24
|
+
if ARGV.empty? || ARGV.include?("--help")
|
|
25
|
+
print_usage
|
|
26
|
+
exit(ARGV.empty? ? 1 : 0)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
command = ARGV.shift
|
|
30
|
+
unless %w[migrate publish].include?(command)
|
|
31
|
+
puts "Unknown command: #{command}"
|
|
32
|
+
print_usage
|
|
33
|
+
exit 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
output_dir = "tmp/wiki-migration"
|
|
37
|
+
docs_path = nil
|
|
38
|
+
force = false
|
|
39
|
+
|
|
40
|
+
while ARGV.any?
|
|
41
|
+
arg = ARGV.shift
|
|
42
|
+
case arg
|
|
43
|
+
when "--output-dir"
|
|
44
|
+
output_dir = ARGV.shift
|
|
45
|
+
raise ArgumentError, "--output-dir requires a value" unless output_dir
|
|
46
|
+
when "--force", "-f"
|
|
47
|
+
force = true
|
|
48
|
+
when "--help"
|
|
49
|
+
print_usage
|
|
50
|
+
exit 0
|
|
51
|
+
else
|
|
52
|
+
docs_path = arg
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
raise ArgumentError, "DOCS_PATH is required" unless docs_path
|
|
57
|
+
|
|
58
|
+
begin
|
|
59
|
+
ENV["WIKI_OUTPUT_DIR"] = output_dir
|
|
60
|
+
ENV["WIKI_FORCE"] = "true" if force
|
|
61
|
+
|
|
62
|
+
if command == "migrate"
|
|
63
|
+
publisher = WikiPromoter::Publisher.from_env(docs_path)
|
|
64
|
+
publisher.migrate
|
|
65
|
+
puts "Migration complete: #{publisher.migrator.pages.size} files written to #{output_dir}/"
|
|
66
|
+
else
|
|
67
|
+
result = WikiPromoter::Publisher.from_env(docs_path).publish
|
|
68
|
+
puts "Publishing complete: #{result.wiki_url}"
|
|
69
|
+
end
|
|
70
|
+
rescue ArgumentError => e
|
|
71
|
+
puts "Error: #{e.message}"
|
|
72
|
+
print_usage
|
|
73
|
+
exit 1
|
|
74
|
+
rescue WikiPromoter::Migrator::CollisionError => e
|
|
75
|
+
puts "Collision Error: #{e.message}"
|
|
76
|
+
exit 1
|
|
77
|
+
rescue => e
|
|
78
|
+
puts "Error: #{e.message}"
|
|
79
|
+
puts e.backtrace
|
|
80
|
+
exit 1
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
main if __FILE__ == $0
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module WikiPromoter
|
|
6
|
+
class TreeNode
|
|
7
|
+
attr_reader :name, :parent, :subdirs, :files
|
|
8
|
+
attr_accessor :prefix
|
|
9
|
+
|
|
10
|
+
def initialize(name, parent = nil)
|
|
11
|
+
@name = name
|
|
12
|
+
@parent = parent
|
|
13
|
+
@subdirs = {}
|
|
14
|
+
@files = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def add_file(path_parts, full_path)
|
|
18
|
+
if path_parts.empty?
|
|
19
|
+
raise "Empty path parts"
|
|
20
|
+
elsif path_parts.size == 1
|
|
21
|
+
@files << full_path
|
|
22
|
+
else
|
|
23
|
+
subdir_name = path_parts.first
|
|
24
|
+
@subdirs[subdir_name] ||= TreeNode.new(subdir_name, self)
|
|
25
|
+
@subdirs[subdir_name].add_file(path_parts[1..], full_path)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Mechanizes the flatten + relink transform for converting hierarchical markdown
|
|
31
|
+
# research/planning trees into GitHub Wiki format. GitHub wikis route pages by
|
|
32
|
+
# basename only (ignoring directory structure), so every file gets a directory-prefixed,
|
|
33
|
+
# cased, and numbered page title (e.g. "77.a.i. Raw Experiment Data"). This groups
|
|
34
|
+
# and orders them logically in the sidebar. Internal relative links are rewritten
|
|
35
|
+
# to reference the new cased, numbered page titles.
|
|
36
|
+
class Migrator
|
|
37
|
+
ENTRY_SLUG_RE = %r{\A(?:.*/)?(\d+)-(.+)\z}
|
|
38
|
+
MD_LINK_RE = /\[([^\]]*)\]\(([^)\s]+)\)/
|
|
39
|
+
MAX_TITLE_LENGTH = 120
|
|
40
|
+
|
|
41
|
+
SUBDIR_STYLES = [:alpha, :roman, :numeric]
|
|
42
|
+
FILE_STYLES = [:numeric, :roman, :numeric]
|
|
43
|
+
|
|
44
|
+
CollisionError = Class.new(StandardError)
|
|
45
|
+
|
|
46
|
+
attr_reader :docs_path, :entry_page_name, :issue_number
|
|
47
|
+
|
|
48
|
+
def initialize(docs_path, entry_page_name: nil)
|
|
49
|
+
@docs_path = docs_path.chomp("/")
|
|
50
|
+
match = ENTRY_SLUG_RE.match(File.basename(@docs_path))
|
|
51
|
+
raise ArgumentError, "#{docs_path} doesn't look like a docs/<issue>-<slug> tree" unless match
|
|
52
|
+
|
|
53
|
+
@issue_number = match[1]
|
|
54
|
+
@entry_page_name = entry_page_name || ENV["ENTRY_PAGE_NAME"] || match[2]
|
|
55
|
+
@metadata = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def source_files
|
|
59
|
+
@source_files ||= Dir.glob(File.join(docs_path, "**", "*.md")).sort
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# {relative_path_within_tree => flattened_wiki_filename_without_extension}
|
|
63
|
+
def wiki_names
|
|
64
|
+
build_metadata! unless @metadata
|
|
65
|
+
@wiki_names ||= source_files.each_with_object({}) do |path, memo|
|
|
66
|
+
rel = relative_path(path)
|
|
67
|
+
memo[rel] = @metadata.fetch(rel).fetch(:wiki_name)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def h1(content)
|
|
72
|
+
content[/^#\s+(.+)$/, 1]&.strip
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The flattened wiki page name for the tree's root entry document. GitHub
|
|
76
|
+
# wikis derive the entry page from the root README.md; a tree without one
|
|
77
|
+
# has no entry page, so fail loudly rather than with a bare KeyError.
|
|
78
|
+
def entry_wiki_name
|
|
79
|
+
wiki_names.fetch("README.md") do
|
|
80
|
+
raise ArgumentError, "#{docs_path} has no root README.md to serve as the wiki entry page"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def clean_title(h1_title)
|
|
85
|
+
return nil if h1_title.nil?
|
|
86
|
+
title = h1_title
|
|
87
|
+
.gsub(/[`"'\/\\:*?<>|]/, "")
|
|
88
|
+
.gsub(/\s+/, " ")
|
|
89
|
+
.strip
|
|
90
|
+
|
|
91
|
+
if title.length > MAX_TITLE_LENGTH
|
|
92
|
+
truncated = title[0, MAX_TITLE_LENGTH - 1].rstrip
|
|
93
|
+
return "#{truncated}…"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
title
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# {wiki_filename_with_extension => rewritten_markdown_content}
|
|
100
|
+
def pages
|
|
101
|
+
check_collisions!
|
|
102
|
+
|
|
103
|
+
titles = source_files.each_with_object({}) do |path, memo|
|
|
104
|
+
rel = relative_path(path)
|
|
105
|
+
build_metadata! unless @metadata
|
|
106
|
+
memo[rel] = @metadata.fetch(rel).fetch(:raw_h1) || @metadata.fetch(rel).fetch(:title)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
source_files.each_with_object({}) do |path, memo|
|
|
110
|
+
rel = relative_path(path)
|
|
111
|
+
content = rewrite_links(File.read(path), rel, titles)
|
|
112
|
+
memo["#{wiki_names.fetch(rel)}.md"] = content
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def relative_path(path)
|
|
119
|
+
Pathname.new(path).relative_path_from(Pathname.new(docs_path)).to_s
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def to_alpha(index)
|
|
123
|
+
result = ""
|
|
124
|
+
while index >= 0
|
|
125
|
+
result = ((index % 26) + 97).chr + result
|
|
126
|
+
index = (index / 26) - 1
|
|
127
|
+
end
|
|
128
|
+
result
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def to_roman(index)
|
|
132
|
+
roman_mapping = {
|
|
133
|
+
10 => "x", 9 => "ix", 5 => "v", 4 => "iv", 1 => "i"
|
|
134
|
+
}
|
|
135
|
+
result = ""
|
|
136
|
+
n = index
|
|
137
|
+
roman_mapping.each do |value, letters|
|
|
138
|
+
while n >= value
|
|
139
|
+
result += letters
|
|
140
|
+
n -= value
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
result
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def format_index(val, style)
|
|
147
|
+
case style
|
|
148
|
+
when :alpha
|
|
149
|
+
to_alpha(val)
|
|
150
|
+
when :roman
|
|
151
|
+
to_roman(val + 1)
|
|
152
|
+
else
|
|
153
|
+
(val + 1).to_s
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def build_metadata!
|
|
158
|
+
@metadata = {}
|
|
159
|
+
root_node = TreeNode.new(docs_path)
|
|
160
|
+
source_files.each do |path|
|
|
161
|
+
rel = relative_path(path)
|
|
162
|
+
root_node.add_file(rel.split("/"), path)
|
|
163
|
+
end
|
|
164
|
+
assign_metadata(root_node, 0, nil)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def assign_metadata(node, depth, parent_prefix)
|
|
168
|
+
if depth == 0
|
|
169
|
+
node.prefix = @issue_number.to_s
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# 1. Assign README.md
|
|
173
|
+
readme = node.files.find { |f| File.basename(f) == "README.md" }
|
|
174
|
+
if readme
|
|
175
|
+
file_prefix = node.prefix
|
|
176
|
+
raw_h1 = h1(File.read(readme))
|
|
177
|
+
cleaned_title = clean_title(raw_h1) || clean_title(File.basename(readme, ".md").tr("-_", " "))
|
|
178
|
+
if depth == 0
|
|
179
|
+
cleaned_title = cleaned_title.sub(/\AIssue\s+#?\d+\s*[\u2014:-]\s*/i, "")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
rel_path = relative_path(readme)
|
|
183
|
+
@metadata[rel_path] = {
|
|
184
|
+
prefix: file_prefix,
|
|
185
|
+
raw_h1: raw_h1,
|
|
186
|
+
title: cleaned_title,
|
|
187
|
+
wiki_name: "#{file_prefix}. #{cleaned_title}"
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# 2. Assign other files in this directory (sorted alphabetically, case-insensitive)
|
|
192
|
+
other_files = node.files.reject { |f| File.basename(f) == "README.md" }.sort_by { |f| File.basename(f).downcase }
|
|
193
|
+
file_style = FILE_STYLES[depth % FILE_STYLES.size]
|
|
194
|
+
other_files.each_with_index do |file_path, idx|
|
|
195
|
+
file_prefix = "#{node.prefix}.#{format_index(idx, file_style)}"
|
|
196
|
+
raw_h1 = h1(File.read(file_path))
|
|
197
|
+
cleaned_title = clean_title(raw_h1) || clean_title(File.basename(file_path, ".md").tr("-_", " "))
|
|
198
|
+
|
|
199
|
+
rel_path = relative_path(file_path)
|
|
200
|
+
@metadata[rel_path] = {
|
|
201
|
+
prefix: file_prefix,
|
|
202
|
+
raw_h1: raw_h1,
|
|
203
|
+
title: cleaned_title,
|
|
204
|
+
wiki_name: "#{file_prefix}. #{cleaned_title}"
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# 3. Assign subdirectories and recurse
|
|
209
|
+
subdirs = node.subdirs.sort_by { |name, _| name.downcase }
|
|
210
|
+
subdir_style = SUBDIR_STYLES[depth % SUBDIR_STYLES.size]
|
|
211
|
+
subdirs.each_with_index do |(name, subdir_node), idx|
|
|
212
|
+
subdir_node.prefix = "#{node.prefix}.#{format_index(idx, subdir_style)}"
|
|
213
|
+
assign_metadata(subdir_node, depth + 1, node.prefix)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Compare case-insensitively: GitHub wiki checkouts (and macOS/Windows
|
|
218
|
+
# working copies) are case-insensitive, so "77.a. Appendix" and
|
|
219
|
+
# "77.a. appendix" would clobber each other even though they are distinct
|
|
220
|
+
# strings.
|
|
221
|
+
def check_collisions!
|
|
222
|
+
dupes = wiki_names.values.group_by(&:downcase).select { |_, names| names.size > 1 }.keys
|
|
223
|
+
return if dupes.empty?
|
|
224
|
+
|
|
225
|
+
offenders = wiki_names.select { |_, name| dupes.include?(name.downcase) }
|
|
226
|
+
raise CollisionError, "Multiple source files would flatten to the same wiki page (case-insensitive): #{offenders}"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def rewrite_links(content, source_relative_path, titles)
|
|
230
|
+
source_dir = File.dirname(source_relative_path)
|
|
231
|
+
in_fence = false
|
|
232
|
+
|
|
233
|
+
content.each_line.map do |line|
|
|
234
|
+
if line.start_with?("```")
|
|
235
|
+
in_fence = !in_fence
|
|
236
|
+
next line
|
|
237
|
+
end
|
|
238
|
+
next line if in_fence
|
|
239
|
+
|
|
240
|
+
line.gsub(MD_LINK_RE) do |match|
|
|
241
|
+
text = $1
|
|
242
|
+
target = $2
|
|
243
|
+
resolved = resolve_internal_target(source_dir, target)
|
|
244
|
+
next match unless resolved
|
|
245
|
+
|
|
246
|
+
rel_target, anchor = resolved
|
|
247
|
+
wiki_name = wiki_names[rel_target]
|
|
248
|
+
next match unless wiki_name
|
|
249
|
+
|
|
250
|
+
"[#{titles[rel_target] || text}](#{WikiPromoter.encode_wiki_link_target(wiki_name)}#{anchor})"
|
|
251
|
+
end
|
|
252
|
+
end.join
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Returns [relative_path_within_tree, "#anchor-or-empty"] if `target` is a
|
|
256
|
+
# relative link to another .md file inside this tree, else nil (external
|
|
257
|
+
# URL, anchor-only link, non-.md target, or a path that escapes the tree).
|
|
258
|
+
def resolve_internal_target(source_dir, target)
|
|
259
|
+
return nil if target.start_with?("#") || target =~ %r{\A[a-z][a-z0-9+.-]*://}i
|
|
260
|
+
|
|
261
|
+
path, anchor = target.split("#", 2)
|
|
262
|
+
return nil unless path&.end_with?(".md")
|
|
263
|
+
|
|
264
|
+
base = (source_dir == ".") ? Pathname.new(docs_path) : Pathname.new(File.join(docs_path, source_dir))
|
|
265
|
+
absolute = (base + path).cleanpath
|
|
266
|
+
root = Pathname.new(docs_path).cleanpath
|
|
267
|
+
return nil unless absolute.to_s.start_with?("#{root}/")
|
|
268
|
+
|
|
269
|
+
rel = absolute.relative_path_from(root).to_s
|
|
270
|
+
return nil unless wiki_names.key?(rel)
|
|
271
|
+
|
|
272
|
+
[rel, anchor ? "##{slugify_anchor(anchor)}" : ""]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Normalize a link's #fragment to GitHub's heading-anchor slug rules
|
|
276
|
+
# (downcase, drop punctuation, spaces -> hyphens) so it still resolves after
|
|
277
|
+
# flattening even when the author wrote it loosely (e.g. "#Two Track
|
|
278
|
+
# Methodology"). Idempotent on already-correct slugs, so links that already
|
|
279
|
+
# use GitHub slugs are unchanged.
|
|
280
|
+
def slugify_anchor(anchor)
|
|
281
|
+
anchor.downcase.gsub(/[^\p{Word}\s-]/u, "").gsub(/\s/, "-")
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Targeted literal find-and-replace for repointing references to a just-removed
|
|
286
|
+
# docs tree (bare relative path or a full GitHub blob permalink into it) at
|
|
287
|
+
# the new wiki pages. String substitution only, not free-form prose
|
|
288
|
+
# rewriting -- keeps this operation's side effects predictable.
|
|
289
|
+
#
|
|
290
|
+
# `page_urls` maps a specific source file path (e.g.
|
|
291
|
+
# "docs/77-slug/research/results.md") to its own flattened wiki URL. Those
|
|
292
|
+
# deep links are repointed at their specific sub-page (longest path first, so
|
|
293
|
+
# a nested file wins over its parent directory) before the whole `docs_path`
|
|
294
|
+
# tree falls back to `entry_url`. When `page_urls` is empty the behavior is
|
|
295
|
+
# identical to a single `docs_path` -> `entry_url` substitution.
|
|
296
|
+
def self.repoint_references(content, docs_path:, entry_url:, repository: nil, page_urls: {}, github_host: "https://github.com")
|
|
297
|
+
substitutions = page_urls
|
|
298
|
+
.sort_by { |path, _| -path.length }
|
|
299
|
+
.push([docs_path, entry_url])
|
|
300
|
+
|
|
301
|
+
substitutions.reduce(content) do |text, (path, url)|
|
|
302
|
+
pattern = if repository
|
|
303
|
+
%r{
|
|
304
|
+
(?:#{Regexp.escape(github_host)}/#{Regexp.escape(repository)}/blob/[^)\s"'\]]+/)?
|
|
305
|
+
#{Regexp.escape(path)}
|
|
306
|
+
[^)\s"'\]]*
|
|
307
|
+
}x
|
|
308
|
+
else
|
|
309
|
+
# If no repository specified, only match bare relative paths
|
|
310
|
+
%r{#{Regexp.escape(path)}[^)\s"'\]]*}
|
|
311
|
+
end
|
|
312
|
+
text.gsub(pattern, url)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module WikiPromoter
|
|
7
|
+
class Publisher
|
|
8
|
+
SUPPORTED_EXTENSIONS = %w[.png .jpg .jpeg .gif .svg .webp .ico .pdf .txt .csv .tsv .json .jsonld .xml .yml .yaml].freeze
|
|
9
|
+
|
|
10
|
+
PublishResult = Struct.new(:wiki_url, :pages) do
|
|
11
|
+
def initialize(wiki_url:, pages:)
|
|
12
|
+
super(wiki_url, pages)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :docs_path, :entry_url, :migrator, :output_dir, :wiki_checkout
|
|
17
|
+
|
|
18
|
+
def initialize(
|
|
19
|
+
docs_path:,
|
|
20
|
+
wiki_repository: nil,
|
|
21
|
+
wiki_deploy_token: nil,
|
|
22
|
+
output_dir: File.join("tmp", "wiki-migration"),
|
|
23
|
+
wiki_checkout: File.join("tmp", "wiki-checkout"),
|
|
24
|
+
roadmap_path: "docs/2026-07-01-roadmap.md",
|
|
25
|
+
update_home_page: true,
|
|
26
|
+
source_repository: nil,
|
|
27
|
+
wiki_clone_url: nil,
|
|
28
|
+
wiki_branch: "master",
|
|
29
|
+
force: false,
|
|
30
|
+
branch: nil,
|
|
31
|
+
entry_page_name: nil,
|
|
32
|
+
github_host: "https://github.com",
|
|
33
|
+
git_user_name: "github-actions[bot]",
|
|
34
|
+
git_user_email: "github-actions[bot]@users.noreply.github.com",
|
|
35
|
+
command_runner: nil,
|
|
36
|
+
capture_runner: nil,
|
|
37
|
+
input: $stdin,
|
|
38
|
+
output: $stdout,
|
|
39
|
+
interactive: nil
|
|
40
|
+
)
|
|
41
|
+
@docs_path = docs_path
|
|
42
|
+
@wiki_repository = wiki_repository ? normalize_wiki_repository(wiki_repository) : nil
|
|
43
|
+
@wiki_deploy_token = wiki_deploy_token
|
|
44
|
+
@output_dir = output_dir
|
|
45
|
+
@wiki_checkout = wiki_checkout
|
|
46
|
+
@roadmap_path = roadmap_path
|
|
47
|
+
@update_home_page = update_home_page
|
|
48
|
+
@source_repository = source_repository || (wiki_repository ? source_repository_from_wiki : nil)
|
|
49
|
+
@wiki_clone_url = wiki_clone_url
|
|
50
|
+
@wiki_branch = wiki_branch
|
|
51
|
+
@force = force
|
|
52
|
+
@branch = branch
|
|
53
|
+
@github_host = (github_host.nil? || github_host.to_s.strip.empty?) ? "https://github.com" : github_host.to_s.sub(%r{/+\z}, "")
|
|
54
|
+
@git_user_name = git_user_name
|
|
55
|
+
@git_user_email = git_user_email
|
|
56
|
+
@command_runner = command_runner
|
|
57
|
+
@capture_runner = capture_runner
|
|
58
|
+
@input = input
|
|
59
|
+
@output = output
|
|
60
|
+
@interactive = interactive
|
|
61
|
+
@migrator = Migrator.new(docs_path, entry_page_name: entry_page_name)
|
|
62
|
+
@entry_url = @source_repository ? build_entry_url : nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.from_env(docs_path)
|
|
66
|
+
new(
|
|
67
|
+
docs_path: docs_path,
|
|
68
|
+
wiki_repository: ENV["WIKI_REPOSITORY"] || ENV["WIKI_REPO"],
|
|
69
|
+
wiki_deploy_token: ENV["WIKI_DEPLOY_TOKEN"],
|
|
70
|
+
output_dir: ENV["WIKI_OUTPUT_DIR"] || File.join("tmp", "wiki-migration"),
|
|
71
|
+
wiki_checkout: ENV["WIKI_CHECKOUT_DIR"] || File.join("tmp", "wiki-checkout"),
|
|
72
|
+
roadmap_path: ENV["ROADMAP_PATH"] || "docs/2026-07-01-roadmap.md",
|
|
73
|
+
update_home_page: truthy_env?("UPDATE_HOME_PAGE", default: true),
|
|
74
|
+
source_repository: ENV["SOURCE_REPOSITORY"] || ENV["GITHUB_REPOSITORY"],
|
|
75
|
+
wiki_clone_url: ENV["WIKI_CLONE_URL"],
|
|
76
|
+
wiki_branch: ENV["WIKI_BRANCH"] || "master",
|
|
77
|
+
force: ENV["WIKI_FORCE"] == "1" || ENV["WIKI_FORCE"] == "true",
|
|
78
|
+
branch: ENV["GITHUB_REF_NAME"],
|
|
79
|
+
entry_page_name: ENV["ENTRY_PAGE_NAME"].then { |value| blank?(value) ? nil : value },
|
|
80
|
+
github_host: ENV["GITHUB_SERVER_URL"] || "https://github.com",
|
|
81
|
+
git_user_name: ENV["GIT_USER_NAME"] || "github-actions[bot]",
|
|
82
|
+
git_user_email: ENV["GIT_USER_EMAIL"] || "github-actions[bot]@users.noreply.github.com"
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.truthy_env?(name, default:)
|
|
87
|
+
value = ENV[name]
|
|
88
|
+
return default if blank?(value)
|
|
89
|
+
|
|
90
|
+
!%w[0 false no off].include?(value.downcase)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.blank?(value)
|
|
94
|
+
value.nil? || value.to_s.strip.empty?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def migrate
|
|
98
|
+
FileUtils.rm_rf(output_dir)
|
|
99
|
+
FileUtils.mkdir_p(output_dir)
|
|
100
|
+
|
|
101
|
+
validate_non_markdown_files!
|
|
102
|
+
|
|
103
|
+
pages = migrator.pages
|
|
104
|
+
pages.each do |filename, content|
|
|
105
|
+
File.write(File.join(output_dir, filename), content)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
non_markdown_files.each do |file|
|
|
109
|
+
rel = relative_path(file)
|
|
110
|
+
target = File.join(output_dir, rel)
|
|
111
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
112
|
+
FileUtils.cp(file, target)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
pages
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def publish
|
|
119
|
+
raise ArgumentError, "wiki_repository required to publish" if blank?(@wiki_repository)
|
|
120
|
+
raise ArgumentError, "wiki_deploy_token required to publish" if blank?(@wiki_deploy_token)
|
|
121
|
+
|
|
122
|
+
# Prevent git from prompting for credentials on a local TTY and hanging.
|
|
123
|
+
ENV["GIT_TERMINAL_PROMPT"] = "0"
|
|
124
|
+
|
|
125
|
+
pages = migrate
|
|
126
|
+
configure_git(".")
|
|
127
|
+
clone_or_update_wiki
|
|
128
|
+
configure_git(wiki_checkout)
|
|
129
|
+
copy_all_files_to_wiki
|
|
130
|
+
add_home_index_entry if @update_home_page
|
|
131
|
+
# Prove we can push the source-branch cleanup before we make the
|
|
132
|
+
# irreversible wiki push, so a permissions/connectivity problem aborts
|
|
133
|
+
# while both remotes are still untouched rather than after the wiki is
|
|
134
|
+
# updated but the branch cleanup can't land.
|
|
135
|
+
verify_source_push_access if Dir.exist?(docs_path)
|
|
136
|
+
commit_and_push_wiki
|
|
137
|
+
cleanup_source_branch
|
|
138
|
+
|
|
139
|
+
PublishResult.new(wiki_url: entry_url, pages: pages)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def blank?(value)
|
|
145
|
+
self.class.blank?(value)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def normalize_wiki_repository(repository)
|
|
149
|
+
normalized = repository.to_s.strip
|
|
150
|
+
normalized = normalized.sub(%r{\Ahttps://github\.com/}, "")
|
|
151
|
+
normalized = normalized.sub(%r{\.git\z}, "")
|
|
152
|
+
normalized = normalized.sub(%r{/+\z}, "")
|
|
153
|
+
normalized.end_with?(".wiki") ? normalized : "#{normalized}.wiki"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def source_repository_from_wiki
|
|
157
|
+
@wiki_repository.sub(/\.wiki\z/, "")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def build_entry_url
|
|
161
|
+
wiki_page_url(migrator.entry_wiki_name)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def wiki_page_url(wiki_name)
|
|
165
|
+
"#{@github_host}/#{@source_repository}/wiki/#{wiki_name.tr(" ", "-")}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# {source_file_path => its own flattened wiki URL} for every page in the
|
|
169
|
+
# tree, used to repoint roadmap deep links at the specific sub-page rather
|
|
170
|
+
# than collapsing everything onto the entry page.
|
|
171
|
+
def page_urls
|
|
172
|
+
return {} unless @source_repository
|
|
173
|
+
|
|
174
|
+
migrator.wiki_names.each_with_object({}) do |(rel, wiki_name), memo|
|
|
175
|
+
memo[File.join(docs_path, rel)] = wiki_page_url(wiki_name)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def clone_url
|
|
180
|
+
@wiki_clone_url || "https://x-access-token:#{@wiki_deploy_token}@github.com/#{@wiki_repository}.git"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def clone_or_update_wiki
|
|
184
|
+
if Dir.exist?(File.join(wiki_checkout, ".git"))
|
|
185
|
+
# Re-point origin to the current clone_url so a rotated token takes
|
|
186
|
+
# effect on an existing checkout (the origin URL is frozen at first
|
|
187
|
+
# clone and credential helpers are disabled).
|
|
188
|
+
run("git", "-C", wiki_checkout, "remote", "set-url", "origin", clone_url)
|
|
189
|
+
run("git", "-C", wiki_checkout, "fetch", "origin")
|
|
190
|
+
run("git", "-C", wiki_checkout, "reset", "--hard", "origin/#{@wiki_branch}")
|
|
191
|
+
else
|
|
192
|
+
FileUtils.mkdir_p(File.dirname(wiki_checkout))
|
|
193
|
+
# Disable credential helpers for the initial clone only; subsequent
|
|
194
|
+
# fetch/push are covered by configure_git(wiki_checkout) which sets
|
|
195
|
+
# credential.helper to empty in the local config.
|
|
196
|
+
run("git", "-c", "credential.helper=", "clone", clone_url, wiki_checkout)
|
|
197
|
+
run("git", "-C", wiki_checkout, "checkout", "-B", @wiki_branch, "origin/#{@wiki_branch}")
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def configure_git(repository_path)
|
|
202
|
+
return if blank?(@git_user_name) || blank?(@git_user_email)
|
|
203
|
+
|
|
204
|
+
run("git", "-C", repository_path, "config", "user.name", @git_user_name)
|
|
205
|
+
run("git", "-C", repository_path, "config", "user.email", @git_user_email)
|
|
206
|
+
run("git", "-C", repository_path, "config", "commit.gpgsign", "false")
|
|
207
|
+
run("git", "-C", repository_path, "config", "tag.gpgsign", "false")
|
|
208
|
+
# Disable credential helpers in the wiki checkout so the token embedded
|
|
209
|
+
# in the clone URL is used instead of system helpers (osxkeychain, gh)
|
|
210
|
+
# that may supply different credentials and cause a 403 on push.
|
|
211
|
+
run("git", "-C", repository_path, "config", "credential.helper", "")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def copy_all_files_to_wiki
|
|
215
|
+
files = Dir.glob(File.join(output_dir, "**", "*")).reject { |p| File.directory?(p) }
|
|
216
|
+
files.each do |file|
|
|
217
|
+
rel = Pathname.new(file).relative_path_from(Pathname.new(output_dir)).to_s
|
|
218
|
+
target = File.join(wiki_checkout, rel)
|
|
219
|
+
|
|
220
|
+
if File.exist?(target) && !FileUtils.compare_file(file, target) && !@force
|
|
221
|
+
raise Error, "Refusing to overwrite existing wiki page with different content: #{rel}. Set WIKI_FORCE=1 to overwrite."
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
225
|
+
FileUtils.cp(file, target)
|
|
226
|
+
end
|
|
227
|
+
run("git", "-C", wiki_checkout, "add", ".")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def non_markdown_files
|
|
231
|
+
Dir.glob(File.join(docs_path, "**", "*"))
|
|
232
|
+
.reject { |p| File.directory?(p) || p.end_with?(".md") || File.basename(p).start_with?(".") }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def validate_non_markdown_files!
|
|
236
|
+
return if @force
|
|
237
|
+
|
|
238
|
+
unsupported = non_markdown_files.select do |file|
|
|
239
|
+
ext = File.extname(file).downcase
|
|
240
|
+
!SUPPORTED_EXTENSIONS.include?(ext)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
return if unsupported.empty?
|
|
244
|
+
|
|
245
|
+
message = "Unsupported non-markdown file type(s) found in docs tree: #{unsupported.map { |f| File.basename(f) }.join(", ")}."
|
|
246
|
+
|
|
247
|
+
if interactive?
|
|
248
|
+
@output.print "#{message}\nProceed anyway? (y/N): "
|
|
249
|
+
response = @input.gets&.chomp
|
|
250
|
+
unless /\Ay(?:es)?\z/i.match?(response)
|
|
251
|
+
raise Error, "Aborted by user due to unsupported file types."
|
|
252
|
+
end
|
|
253
|
+
else
|
|
254
|
+
raise Error, "#{message} Use WIKI_FORCE=1, the --force CLI option, or the force action input to override."
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def relative_path(path)
|
|
259
|
+
Pathname.new(path).relative_path_from(Pathname.new(docs_path)).to_s
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def interactive?
|
|
263
|
+
return @interactive unless @interactive.nil?
|
|
264
|
+
|
|
265
|
+
@input.tty? && !ENV["CI"]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def add_home_index_entry
|
|
269
|
+
home_path = File.join(wiki_checkout, "Home.md")
|
|
270
|
+
return unless File.exist?(home_path)
|
|
271
|
+
|
|
272
|
+
entry_wiki_name = migrator.entry_wiki_name
|
|
273
|
+
entry_page_path = File.join(output_dir, "#{entry_wiki_name}.md")
|
|
274
|
+
entry_h1 = migrator.h1(File.read(entry_page_path)) || entry_wiki_name
|
|
275
|
+
# Em dash matches the heading format used in every hand-authored Home.md
|
|
276
|
+
# entry so the dedupe guard below actually recognizes existing entries.
|
|
277
|
+
heading = "## Issue ##{migrator.issue_number} — #{entry_h1}"
|
|
278
|
+
new_section = <<~SECTION
|
|
279
|
+
#{heading}
|
|
280
|
+
|
|
281
|
+
**Migrated to the wiki.** See [#{entry_h1}](#{WikiPromoter.encode_wiki_link_target(entry_wiki_name)}) for the full research.
|
|
282
|
+
|
|
283
|
+
SECTION
|
|
284
|
+
|
|
285
|
+
home = File.read(home_path)
|
|
286
|
+
return if home.include?(heading)
|
|
287
|
+
|
|
288
|
+
anchor = "## Settled Decisions"
|
|
289
|
+
if home.include?(anchor)
|
|
290
|
+
File.write(home_path, home.sub(anchor, new_section + anchor))
|
|
291
|
+
else
|
|
292
|
+
# No anchor heading — append the entry and create the anchor so
|
|
293
|
+
# future entries insert above it and the layout converges.
|
|
294
|
+
File.write(home_path, home.sub(/\n*\z/, "\n\n") + new_section + anchor)
|
|
295
|
+
end
|
|
296
|
+
run("git", "-C", wiki_checkout, "add", "Home.md")
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def commit_and_push_wiki
|
|
300
|
+
return unless staged_changes?(wiki_checkout)
|
|
301
|
+
|
|
302
|
+
run("git", "-C", wiki_checkout, "commit", "-m", "Migrate #{docs_path} research to the wiki")
|
|
303
|
+
run("git", "-C", wiki_checkout, "push", "origin", "HEAD:#{@wiki_branch}")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def verify_source_push_access
|
|
307
|
+
run("git", "push", "--dry-run", "origin", "HEAD:refs/heads/#{source_branch}")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def cleanup_source_branch
|
|
311
|
+
unless Dir.exist?(docs_path)
|
|
312
|
+
puts "#{docs_path} already removed from this branch; skipping the cleanup commit."
|
|
313
|
+
return
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
repoint_roadmap
|
|
317
|
+
run("git", "rm", "-r", "--quiet", docs_path)
|
|
318
|
+
run("git", "add", @roadmap_path) if File.exist?(@roadmap_path)
|
|
319
|
+
return unless staged_changes?(".")
|
|
320
|
+
|
|
321
|
+
run("git", "commit", "-m", "Migrate #{docs_path} research to the wiki; drop local tree")
|
|
322
|
+
run("git", "push", "origin", "HEAD:refs/heads/#{source_branch}")
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def repoint_roadmap
|
|
326
|
+
return unless File.exist?(@roadmap_path)
|
|
327
|
+
|
|
328
|
+
original = File.read(@roadmap_path)
|
|
329
|
+
updated = WikiPromoter.repoint_references(
|
|
330
|
+
original,
|
|
331
|
+
docs_path: docs_path,
|
|
332
|
+
entry_url: entry_url,
|
|
333
|
+
repository: @source_repository,
|
|
334
|
+
page_urls: page_urls,
|
|
335
|
+
github_host: @github_host
|
|
336
|
+
)
|
|
337
|
+
File.write(@roadmap_path, updated) if updated != original
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def source_branch
|
|
341
|
+
branch = @branch || capture("git", "symbolic-ref", "-q", "--short", "HEAD").strip
|
|
342
|
+
raise Error, "Could not determine a branch to push to" if blank?(branch)
|
|
343
|
+
|
|
344
|
+
branch
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def staged_changes?(repository_path)
|
|
348
|
+
!system("git", "-C", repository_path, "diff", "--cached", "--quiet")
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def run(*cmd)
|
|
352
|
+
if @command_runner
|
|
353
|
+
@command_runner.call(cmd)
|
|
354
|
+
elsif !system(*cmd)
|
|
355
|
+
raise Error, "Command failed: #{cmd.join(" ")}"
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def capture(*cmd)
|
|
360
|
+
return @capture_runner.call(cmd) if @capture_runner
|
|
361
|
+
|
|
362
|
+
stdout, status = Open3.capture2(*cmd)
|
|
363
|
+
raise Error, "Command failed: #{cmd.join(" ")}" unless status.success?
|
|
364
|
+
|
|
365
|
+
stdout
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "rake"
|
|
5
|
+
require_relative "../wiki_promoter"
|
|
6
|
+
|
|
7
|
+
namespace :wiki do
|
|
8
|
+
desc "Flatten and relink a docs tree, outputting to tmp/wiki-migration/"
|
|
9
|
+
task :migrate, [:docs_path] do |_t, args|
|
|
10
|
+
docs_path = args[:docs_path] || ENV["DOCS_PATH"]
|
|
11
|
+
raise ArgumentError, "docs_path required (pass as task argument or DOCS_PATH env var)" unless docs_path
|
|
12
|
+
|
|
13
|
+
entry_page_name = ENV["ENTRY_PAGE_NAME"]
|
|
14
|
+
|
|
15
|
+
migrator = WikiPromoter::Migrator.new(docs_path, entry_page_name: entry_page_name)
|
|
16
|
+
output_dir = ENV["WIKI_OUTPUT_DIR"] || File.join("tmp", "wiki-migration")
|
|
17
|
+
FileUtils.rm_rf(output_dir)
|
|
18
|
+
FileUtils.mkdir_p(output_dir)
|
|
19
|
+
|
|
20
|
+
# Write flattened pages
|
|
21
|
+
migrator.pages.each do |filename, content|
|
|
22
|
+
path = File.join(output_dir, filename)
|
|
23
|
+
File.write(path, content)
|
|
24
|
+
puts " wrote #{path}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
puts "Migration complete: #{migrator.pages.size} files written to #{output_dir}/"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
desc "Publish docs to wiki, update Home.md, repoint roadmap, and delete source"
|
|
31
|
+
task :publish, [:docs_path] do |_t, args|
|
|
32
|
+
docs_path = args[:docs_path] || ENV["DOCS_PATH"]
|
|
33
|
+
raise ArgumentError, "docs_path required (pass as task argument or DOCS_PATH env var)" unless docs_path
|
|
34
|
+
|
|
35
|
+
result = WikiPromoter::Publisher.from_env(docs_path).publish
|
|
36
|
+
puts "Publishing complete: #{result.wiki_url}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "wiki_promoter/version"
|
|
4
|
+
require_relative "wiki_promoter/migrator"
|
|
5
|
+
require_relative "wiki_promoter/publisher"
|
|
6
|
+
|
|
7
|
+
module WikiPromoter
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Percent-encode a wiki page name for use as a markdown link target.
|
|
11
|
+
# GitHub wiki renders [text](page name) as plain text when the URL
|
|
12
|
+
# contains unencoded reserved characters: '#' starts a fragment, '%'
|
|
13
|
+
# creates invalid escape sequences, '(' ')' break the markdown link
|
|
14
|
+
# syntax. Encode '%' first to avoid double-encoding.
|
|
15
|
+
def self.encode_wiki_link_target(wiki_name)
|
|
16
|
+
wiki_name
|
|
17
|
+
.gsub("%", "%25")
|
|
18
|
+
.gsub(" ", "%20")
|
|
19
|
+
.gsub("#", "%23")
|
|
20
|
+
.gsub("(", "%28")
|
|
21
|
+
.gsub(")", "%29")
|
|
22
|
+
end
|
|
23
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: wiki_promoter
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- CPB
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-11 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: standard
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: minitest
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '5.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '5.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: dotenv
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '2.8'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '2.8'
|
|
69
|
+
description: A pure-Ruby gem that automates promoting research/planning documents
|
|
70
|
+
from a docs/ hierarchy into a GitHub Wiki. Includes Rake tasks for local use and
|
|
71
|
+
a composite GitHub Action for workflow automation.
|
|
72
|
+
email:
|
|
73
|
+
- cpb@github.com
|
|
74
|
+
executables:
|
|
75
|
+
- wiki-promoter
|
|
76
|
+
extensions: []
|
|
77
|
+
extra_rdoc_files: []
|
|
78
|
+
files:
|
|
79
|
+
- CHANGELOG.md
|
|
80
|
+
- LICENSE.txt
|
|
81
|
+
- README.md
|
|
82
|
+
- action/action.yml
|
|
83
|
+
- exe/wiki-promoter
|
|
84
|
+
- lib/wiki_promoter.rb
|
|
85
|
+
- lib/wiki_promoter/migrator.rb
|
|
86
|
+
- lib/wiki_promoter/publisher.rb
|
|
87
|
+
- lib/wiki_promoter/tasks.rb
|
|
88
|
+
- lib/wiki_promoter/version.rb
|
|
89
|
+
homepage: https://github.com/cpb/wiki_promoter
|
|
90
|
+
licenses:
|
|
91
|
+
- MIT
|
|
92
|
+
metadata: {}
|
|
93
|
+
post_install_message:
|
|
94
|
+
rdoc_options: []
|
|
95
|
+
require_paths:
|
|
96
|
+
- lib
|
|
97
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - ">="
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: 3.3.0
|
|
102
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
103
|
+
requirements:
|
|
104
|
+
- - ">="
|
|
105
|
+
- !ruby/object:Gem::Version
|
|
106
|
+
version: '0'
|
|
107
|
+
requirements: []
|
|
108
|
+
rubygems_version: 3.5.22
|
|
109
|
+
signing_key:
|
|
110
|
+
specification_version: 4
|
|
111
|
+
summary: Flatten, migrate, and sync GitHub Markdown research trees to GitHub Wiki
|
|
112
|
+
test_files: []
|