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 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WikiPromoter
4
+ VERSION = "0.1.0"
5
+ 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: []