import が「GitHub → stdout(新規YAML生成)」なのに対し、import --into は「GitHub → ローカルマニフェスト(既存YAML更新)」。apply の逆方向。
gh infra import owner/repo --into=./repos/manifest.yaml # 特定ファイル
gh infra import owner/repo --into=./repos/ # ディレクトリ検索
1. Parse --into のパス → マニフェスト群を取得
2. Match: owner/repo に該当するリソースを特定
3. Fetch: GitHub から現在の状態を取得
4. Plan: ローカル vs GitHub のフィールド単位比較 → YAML パッチ生成
5. Display: diff を表示して確認を求める
6. Apply: ローカルファイル/マニフェストを更新
PoC (babarot/pull-command ブランチ) の設計を踏襲し、関数ベースの API とする。
既存の repository.Change は apply 用の前向き表現であり、import には流用しない。
import 専用の planner でフィールド単位比較 → YAML edit を直接生成する。
package importer
// Target は import --into の対象リポジトリ
type Target struct {
Owner string
Name string
}
func (t Target) FullName() string {
return t.Owner + "/" + t.Name
}
// Matches はマニフェスト内で owner/repo に一致したリソース群。
// Repository と RepositorySet を分離して保持する(書き戻し先の YAML パスが異なるため)。
type Matches struct {
Repositories []*manifest.RepositoryDocument
RepositorySets []*manifest.RepositoryDocument
FileSets []*manifest.FileDocument
}
func (m Matches) HasRepo() bool { return len(m.Repositories) > 0 || len(m.RepositorySets) > 0 }
func (m Matches) HasFiles() bool { return len(m.FileSets) > 0 }
func (m Matches) IsEmpty() bool { return !m.HasRepo() && !m.HasFiles() }
// TargetMatches は Target と対応する Matches のペア
type TargetMatches struct {
Target Target
Matches Matches
}
// FieldDiff はフィールド単位の差分(import 専用の diff 表現)。
// repository.Change は apply 用の前向き表現であり、import には使わない。
type FieldDiff struct {
Field string // "description", "features.issues", "rulesets.my-rule.enforcement", etc.
Old any // ローカル値
New any // GitHub 値
}
// RepoPlan は Repository/RepositorySet 単位の変更計画
type RepoPlan struct {
Diffs []FieldDiff // フィールド単位の差分(表示用)
ManifestEdits map[string][]byte // YAML パッチ結果
UpdatedDocs int
}
func (rp RepoPlan) HasChanges() bool {
return len(rp.Diffs) > 0
}
// IntoPlan は変更計画全体(複数ターゲットの結果を統合)。
// 表示用データのみを保持し、適用用の内部状態(manifestBytes 等)は持たない。
// Apply は独立関数 ApplyInto() で行う。
type IntoPlan struct {
RepoDiffs []FieldDiff // 全ターゲットの repo diff を統合
FileChanges []Change // ファイル変更
ManifestEdits map[string][]byte // YAML パッチ結果(path → 更新後の全体バイト列)
UpdatedDocs int
}
func (p *IntoPlan) AddRepoPlan(rp RepoPlan) {
p.RepoDiffs = append(p.RepoDiffs, rp.Diffs...)
for path, data := range rp.ManifestEdits {
p.ManifestEdits[path] = data
}
p.UpdatedDocs += rp.UpdatedDocs
}
func (p IntoPlan) HasChanges() bool {
return len(p.RepoDiffs) > 0 || HasFileChanges(p.FileChanges)
}// WriteMode はファイルの書き戻し方法(デバッグしやすいよう string 型)
type WriteMode string
const (
WriteSource WriteMode = "source" // ローカルソースファイルを上書き
WriteInline WriteMode = "inline" // YAML 内の content: ブロックを AST 編集
WriteSkip WriteMode = "skip" // スキップ
)
// Change はファイル1つの import 方向の変更
type Change struct {
Target string // owner/repo
Path string // リポジトリ内パス
Type fileset.ChangeType // create/update/noop
Current string // ローカル内容
Desired string // GitHub 上の内容
WriteMode WriteMode
LocalTarget string // 書き戻し先パス(WriteSource 時)
ManifestPath string // マニフェストパス(WriteInline 時)
DocIndex int
YAMLPath string // e.g. $.spec.files[0].content
Reason string // スキップ理由
Warnings []string
}// FindMatches は ParseResult から owner/repo に該当するリソースを探す。
// Repository と RepositorySet を分離して返す。
func FindMatches(parsed *manifest.ParseResult, fullName string) Matches関数ベースの API。Importer 構造体は持たず、必要な依存は引数で渡す。
複数ターゲットを1回の呼び出しで処理し、manifestBytes を全ターゲット間で共有する。
これにより同一マニフェストファイル内の複数リポジトリへのパッチが正しく累積される。
// PlanInto は全ターゲットに対して変更計画を立てる。
// manifestBytes を内部で共有し、同一ファイルへの逐次パッチを正しく累積する。
func PlanInto(targets []TargetMatches, runner gh.Runner, printer ui.Printer, tracker *ui.RefreshTracker) (IntoPlan, error)内部フロー(各ターゲットについて逐次実行):
- GitHub 状態を fetch(
repository.FetchRepository)—ctx付きシグネチャをそのまま使う ToManifest()で manifest.Repository に変換- Repository マッチ →
PlanRepository()でフィールド比較 + YAML パッチ - RepositorySet マッチ →
PlanRepositorySet()で最小 override 再構成 + YAML パッチ - FileSet マッチ →
PlanFiles()でファイル単位の diff
各ステップで manifestBytes を共有 map から読み書きするため、
先のターゲットのパッチ結果が後のターゲットに引き継がれる。
逐次実行制約: 同一マニフェストファイルへのパッチが累積する必要があるため、 ターゲット間のループは並列化してはならない。各ターゲットを逐次処理する。 (GitHub 状態の fetch は並列化可能だが、manifestBytes への書き込みは逐次。)
mutate スコープ: manifestBytes は PlanInto 内でのみ生成・更新される。
PlanInto から返る IntoPlan.ManifestEdits は最終結果のスナップショットであり、
呼び出し元(cmd 層・infra 層)が manifestBytes を直接操作することはない。
既存の repository.Diff / repository.Change は apply 用の表現(Children 階層、Create/Delete のセマンティクス)であり、
import 方向に流用すると branch protection / rulesets / actions の逆変換で破綻する。
import 専用の planner を作り、フィールド単位で local effective spec vs GitHub imported spec を比較し、
YAML パッチを直接生成する。
type RepoPlanInput struct {
Repos []*manifest.RepositoryDocument
GitHubState *repository.CurrentState
Imported *manifest.Repository
Resolver *manifest.Resolver
ReadManifestBytes func(string) error
ManifestBytes map[string][]byte
}
// PlanRepository は Repository リソースのフィールド単位比較 + YAML パッチを生成。
// repository.Change には依存しない。
func PlanRepository(input RepoPlanInput) (RepoPlan, error)
// PlanRepositorySet は RepositorySet の最小 override を再構成して書き戻す。
// full spec ではなく defaults との差分のみを $.repositories[N].spec に書く。
func PlanRepositorySet(input RepoPlanInput) (RepoPlan, error)フィールド比較の対象:
各フィールドについて local effective value と GitHub imported value を比較し、
差分があれば FieldDiff に記録 + YAML パッチを積む。
- scalar:
description,homepage,visibility,archived - list:
topics - nested map:
features.*,merge_strategy.*,actions.* - complex:
branch_protection,rulesets(各ルール名単位で比較) - skip:
secrets(ローカルが source of truth、値が読めない) - preserve:
variables(GitHub API で値も読めるので通常通り比較)
Secrets の扱い: GitHub API は secret の値を返さない(名前のみ)。 書き戻し前にローカルの secrets をそのまま保持する:
// PlanRepository / PlanRepositorySet 内、ReplaceNode の前に:
imported.Spec.Secrets = localRepo.Spec.SecretsRepositorySet の最小 override 再構成:
full spec を $.repositories[N].spec に書くのではなく、
defaults との差分のみを書き戻す。これにより DRY を維持し、defaults の将来変更が効き続ける。
// minimalOverride は imported spec から defaults と同一のフィールドを除去し、
// override として必要な最小の spec を返す。
func minimalOverride(defaults manifest.RepositorySpec, imported manifest.RepositorySpec) manifest.RepositorySpecフィールドタイプごとのルール:
- scalar: defaults と同値なら zero value(
yaml:",omitempty"で消える)、違えば override - map-like (
features,merge_strategy): key 単位で defaults と違うものだけ残す - list (
topics等): defaults と同値なら omit、違えば丸ごと override - secrets: ローカル override を保持、import 対象外
minimalOverride が全フィールド zero value なら spec 自体を書かない(= defaults のみで十分)。
パーサー拡張: RepositoryDocument に DefaultsSpec と OriginalEntrySpec を追加し、
parseRepositorySet() で保持する(展開前の defaults と元の override spec を記録)。
diff 表示: OriginalEntrySpec → newOverride の差分をユーザーに見せる。
これにより「GitHub 実態に合わせる」と「manifest 構造を壊さない」の両方を満たす。
// PlanRepositorySet 内:
effectiveImported := imported.Spec
newOverride := minimalOverride(defaults.Spec, effectiveImported)
// diff 表示は OriginalEntrySpec → newOverride
// YAML patch は $.repositories[N].spec を newOverride に置換// PlanFiles は FileSet 内の各ファイルについて GitHub vs ローカルの diff を計算
func PlanFiles(fetchFile FileContentFetcher, fileSets []*manifest.FileDocument, filterRepo string) ([]Change, error)各 FileEntry の WriteMode 判定:
source: ./local.yaml→ WriteSource(ローカルファイル上書き)content: |(inline) → WriteInline(YAML AST パッチ)source: github://...→ WriteSkip(リモートは書き戻し不可)reconcile: create_only→ WriteSkipVarsorPatches使用 → WriteSkip(テンプレート/パッチの逆変換は不可能)
テンプレート・パッチのスキップ理由: GitHub 上のコンテンツはレンダリング/パッチ適用済み。 これをソースに書き戻すとテンプレートが壊れ、同じ FileSet を共有する他リポジトリに波及する。
// planImportEntry 内:
if len(file.Vars) > 0 || len(file.Patches) > 0 {
change.WriteMode = WriteSkip
change.Type = fileset.NoOp
change.Reason = "uses templates or patches"
return change
}// ApplyInto は計画に基づいてローカルファイル/マニフェストを更新する独立関数。
// IntoPlan はメソッドを持たず、表示用データのみ保持する。
// manifestEdits は PlanInto が生成した最終結果(path → 更新後バイト列)。
func ApplyInto(plan IntoPlan) errorgoccy/go-yaml の AST + Path API を使用。コメント保持・multi-doc YAML 対応。
// ReplaceContent は YAML 内の literal block (content: |) の内容を更新
func ReplaceContent(data []byte, docIndex int, path, content string) ([]byte, error)
// ReplaceNode は構造化ノード(spec: オブジェクト等)を置換
func ReplaceNode(data []byte, docIndex int, path string, value any) ([]byte, error)PoC に倣い、DocumentInfo 方式ではなく型安全なドキュメントラッパーを採用する。
ParseResult は破壊的に変更する(従来の field Repositories / FileSets は削除)。
既存コードとの互換のため Repositories() / FileSets() ヘルパーメソッドを提供する。
既存の全 call site(parsed.Repositories → parsed.Repositories() 等)を一括で置換する。
対象: cmd/validate.go, internal/infra/plan.go, internal/infra/apply.go 等。
type ParseResult struct {
RepositoryDocs []*RepositoryDocument
FileDocs []*FileDocument
Warnings []string
}
// Repositories は既存コードとの互換用ヘルパー。
// plan/apply パスは Document メタデータを必要としないため、こちらを使う。
func (r *ParseResult) Repositories() []*Repository {
repos := make([]*Repository, len(r.RepositoryDocs))
for i, d := range r.RepositoryDocs {
repos[i] = d.Resource
}
return repos
}
// FileSets は既存コードとの互換用ヘルパー。
func (r *ParseResult) FileSets() []*FileSet {
sets := make([]*FileSet, len(r.FileDocs))
for i, d := range r.FileDocs {
sets[i] = d.Resource
}
return sets
}
// RepositoryDocument は Repository + パース元メタデータ
type RepositoryDocument struct {
Resource *Repository
SourcePath string // パースしたファイルパス
DocIndex int // multi-doc YAML 内の位置(0-based)
FromSet bool // RepositorySet 由来か
SetEntryIndex int // RepositorySet 内の位置(FromSet=true 時のみ有効)
DefaultsSpec *RepositorySetDefaults // RepositorySet の defaults(FromSet=true 時のみ)
OriginalEntrySpec *RepositorySpec // RepositorySet 内の元の override spec(merge 前、FromSet=true 時のみ)
}
// FileDocument は FileSet + パース元メタデータ + 解決済みファイル。
// Resource.Spec.Files はパース直後のソース未解決状態(source: フィールドが文字列のまま)。
// FileDocument.Files はソース解決済み(ローカルファイルの内容読み込み済み、OriginalSource 設定済み)。
// import 時のファイル内容比較には FileDocument.Files を使うこと。
type FileDocument struct {
Resource *FileSet // パース直後の FileSet(Spec.Files はソース未解決)
SourcePath string
DocIndex int
Files []FileEntry // source 解決済み(OriginalSource 設定済み)
}FileEntry に OriginalSource string フィールドを追加(source 解決時にローカルファイルパスを記録)。
パーサーの具体的な変更箇所:
parseDocument(): 返り値をRepositoryDocument/FileDocumentでラップする。SourcePath(パース対象のファイルパス)とDocIndex(multi-doc YAML 内の 0-based 位置)はここで設定。parseRepositorySet(): 展開した各 Repository にFromSet: true、SetEntryIndex: i(repositories[]内の位置)、DefaultsSpec(展開前の defaults)、OriginalEntrySpec(merge 前の元の override spec)を付与。DefaultsSpecはminimalOverride()で使用し、OriginalEntrySpecは diff 表示で使用する。ParseAll(): 各ドキュメントの結果をParseResult.RepositoryDocs/ParseResult.FileDocsに追加。
SourceResolver.ResolveFiles(): ローカルファイル(source: ./path/to/file)を解決する際にFileEntry.OriginalSourceにローカルファイルパスをセット。github://スキームの場合はセットしない(WriteSkip 対象)。
func newImportCmd() *cobra.Command {
var intoPath string
cmd := &cobra.Command{
Use: "import <owner/repo> [owner/repo ...]",
Short: "Export existing repository settings as YAML",
Long: "Fetch current GitHub repository settings and output them as gh-infra YAML.\n" +
"With --into, pull GitHub state back into existing local manifests.",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if intoPath != "" {
return runImportInto(args, intoPath)
}
return runImport(args)
},
}
cmd.Flags().StringVar(&intoPath, "into", "",
"Pull GitHub state into existing local manifests (dir or file path)")
return cmd
}
func runImportInto(args []string, intoPath string) error {
parsed, err := manifest.ParseAll(intoPath)
if err != nil {
return err
}
// 全ターゲットのマッチを先に集める
var targets []importer.TargetMatches
for _, arg := range args {
target := parseTarget(arg)
matches := importer.FindMatches(parsed, target.FullName())
targets = append(targets, importer.TargetMatches{Target: target, Matches: matches})
}
// infra 層経由で Plan(runner/printer/tracker の構築は infra が担う)
plan, printer, err := infra.ImportInto(targets, intoPath)
if err != nil {
return err
}
if !plan.HasChanges() {
printer.Message("No changes detected")
return nil
}
// diff 表示 → 確認(既存の ui.ConfirmWithDiff を再利用)
entries := buildDiffEntries(plan)
ok, err := printer.ConfirmWithDiff("Apply import changes?", entries)
if err != nil || !ok {
return err
}
return importer.ApplyInto(plan)
}既存のアーキテクチャ(cmd → infra → domain)と一貫させるため、
internal/infra/import_into.go に薄いラッパーを置く。
ドメインロジックは internal/importer/ に残し、infra は runner/printer/tracker の構築と
importer.PlanInto() の呼び出しのみ担う。既存の import.go(infra 層)は触らない。
// internal/infra/import_into.go
func ImportInto(targets []importer.TargetMatches, intoPath string) (*importer.IntoPlan, ui.Printer, error) {
runner := gh.NewRunner(false)
printer := ui.NewPrinter()
tracker := ui.NewRefreshTracker(printer)
plan, err := importer.PlanInto(targets, runner, printer, tracker)
return plan, printer, err
}cmd/
import.go # --into フラグ追加、分岐ロジック
internal/
manifest/
types.go # ParseResult → RepositoryDocument/FileDocument 方式に拡張
parser.go # パース時にドキュメントメタデータを記録(DefaultsSpec 含む)
fileset/
types.go # ResolvedFile, FileOrigin 追加
infra/
import_into.go # ImportInto() 薄いラッパー(runner/printer/tracker 構築)
importer/ # 新規パッケージ
types.go # Target, Matches, FieldDiff, RepoPlan, IntoPlan
into.go # PlanInto() オーケストレーション
match.go # FindMatches()
repository.go # PlanRepository(), PlanRepositorySet(), minimalOverride()
files.go # PlanFiles(), ApplyFiles(), WriteMode, Change
apply.go # ApplyInto()(独立関数、IntoPlan を受け取る)
yamledit/ # 新規パッケージ
yamledit.go # ReplaceContent, ReplaceNode(goccy/go-yaml AST)
| Phase | 内容 | 複雑度 |
|---|---|---|
| 1 | ParseResult 拡張(DefaultsSpec 含む)+ match.go + --into フラグ配線 |
低 |
| 2a | PlanRepository — scalar/list/nested map フィールドの比較 + yamledit.ReplaceNode 呼び出し |
中 |
| 2b | PlanRepositorySet — minimalOverride() + DefaultsSpec/OriginalEntrySpec 参照 |
高 |
| 2c | complex fields — branch_protection / rulesets の各ルール名単位比較 | 中 |
| 2d | secrets 保持 + variables 比較 | 低 |
| 3 | files.go + WriteSource + template/patch スキップ |
中 |
| 4 | yamledit/ + WriteInline(inline content 更新) |
高 |
Phase 1 → Phase 2a → Phase 2b
→ Phase 2c (2a 完了後、2b/2c/2d は並列可能)
→ Phase 2d
Phase 1 → Phase 3
Phase 3 → Phase 4
ParseResultの型変更後、go build ./...が通る- 既存の全テスト(
go test ./...)がパスする(既存 plan/apply パスが壊れていない) FindMatchesが Repository / RepositorySet / FileSet の各リソースに対して正しくマッチを返す--intoフラグ指定時にrunImportIntoに分岐する(--intoなしは既存のrunImportのまま)
PlanRepositoryが scalar (description,visibility, etc.) / list (topics) / nested map (features.*,merge_strategy.*,actions.*) の差分を正しくFieldDiffとして返す- 差分なしの場合に空の
RepoPlanを返す yamledit.ReplaceNodeを使ってmanifestBytesが更新される
minimalOverrideが defaults と同値のフィールドを zero value にし、異なるフィールドのみ残す- 全フィールドが defaults と同値の場合、spec 自体が空(omitempty で消える)
PlanRepositorySetの diff 表示がOriginalEntrySpec→newOverrideになっている- YAML パッチが
$.repositories[N].specのみを書き換え、defaults や他エントリを壊さない
- branch_protection: pattern 単位でフィールド比較し、追加/変更/削除を
FieldDiffに記録 - rulesets: name 単位でフィールド比較(bypass_actors, conditions, rules 含む)
- ローカルにない branch_protection/ruleset が GitHub にある場合は FieldDiff に記録(ユーザーが判断できるよう表示)
- secrets: ローカル値がそのまま保持され、GitHub 側の secret 名リストとの差分は取らない
- variables: GitHub API の値とローカル値を比較し、差分を
FieldDiffに記録
- WriteSource: ローカルファイルパスに GitHub の内容が書き込まれる
- WriteSkip: Vars/Patches 付き、
github://source、create_onlyの各条件で正しくスキップ + Reason 設定 - 共有ソース(repositories が2以上)の場合に warning が
Change.Warningsに追記される
ReplaceContentがコメントを保持したまま literal block の内容を更新ReplaceNodeが multi-doc YAML の指定ドキュメントのみを更新し、他ドキュメントに影響しない- WriteInline の E2E: YAML 内の
content: |ブロックが更新される
各フェーズは対応するテストが全パスすることを完了条件とする。
既存テスト(go test ./...)は全フェーズを通じて常にパスしなければならない。
TestParseResult_Repositories_HelperMethod
- RepositoryDocs から []*Repository を正しく抽出
TestParseResult_FileSets_HelperMethod
- FileDocs から []*FileSet を正しく抽出
TestParseRepositorySet_DefaultsSpec
- parseRepositorySet 後に RepositoryDocument.DefaultsSpec が設定されている
TestParseRepositorySet_OriginalEntrySpec
- merge 前の override spec が OriginalEntrySpec に記録されている
TestParseRepositorySet_OriginalEntrySpec_Empty
- override なしのエントリで OriginalEntrySpec が空の RepositorySpec
TestFindMatches_Repository
- owner/repo が Repository に一致 → Matches.Repositories に入る
TestFindMatches_RepositorySet
- owner/repo が RepositorySet 由来 → Matches.RepositorySets に入る
TestFindMatches_FileSet
- owner/repo が FileSet の repositories に含まれる → Matches.FileSets に入る
TestFindMatches_NotFound
- 存在しない owner/repo → Matches.IsEmpty() == true
TestFindMatches_Multiple
- 同一 repo が Repository と FileSet 両方にマッチ → 両方のフィールドにセット
TestPlanRepository_NoDiff
- ローカルと GitHub が同一 → RepoPlan.HasChanges() == false
TestPlanRepository_ScalarDiff
- description/visibility 変更 → 対応する FieldDiff が生成
TestPlanRepository_TopicsDiff
- topics のリスト差分 → FieldDiff に記録
TestPlanRepository_FeaturesDiff
- features.issues 等の nested map 差分
TestPlanRepository_MergeStrategyDiff
- merge_strategy の各フィールド差分
TestPlanRepository_ActionsDiff
- actions.* の差分(selected_actions 含む)
TestPlanRepository_ManifestBytesUpdated
- Plan 後に manifestBytes[path] が更新されている
TestMinimalOverride_AllSameAsDefaults
- 全フィールドが defaults と同値 → 返り値が zero value RepositorySpec
TestMinimalOverride_ScalarOverride
- description が異なる → description のみ残る
TestMinimalOverride_FeaturePartialOverride
- features の一部が異なる → 異なるキーのみ残る
TestMinimalOverride_TopicsOverride
- topics が異なる → 丸ごと override
TestMinimalOverride_SecretsPreserved
- ローカル secrets は import 対象外 → override からそのまま保持
TestPlanRepositorySet_DiffShowsOriginalToNew
- diff 表示が OriginalEntrySpec → newOverride であることを検証
TestPlanRepositorySet_PatchTargetsEntryOnly
- YAML パッチが $.repositories[N].spec のみを書き換え
TestPlanRepository_BranchProtection_Update
- existing pattern のフィールド変更 → FieldDiff 記録
TestPlanRepository_BranchProtection_NewOnGitHub
- ローカルにない pattern が GitHub にある → FieldDiff 記録
TestPlanRepository_Rulesets_Update
- name 単位で enforcement/rules/conditions の変更検出
TestPlanRepository_Rulesets_BypassActors
- bypass_actors の変更検出
TestPlanRepository_SecretsPreserved
- Plan 後の manifestBytes 内で secrets がローカル値のまま
TestPlanRepository_VariablesDiff
- variables の値が異なる → FieldDiff 記録
TestPlanRepository_VariablesNoDiff
- variables が同値 → diff なし
TestPlanFiles_WriteSource
- source: ./local.yaml → WriteMode == WriteSource, LocalTarget 設定
TestPlanFiles_WriteInline
- content: | → WriteMode == WriteInline, YAMLPath 設定
TestPlanFiles_SkipGitHubSource
- source: github://... → WriteMode == WriteSkip, Reason 設定
TestPlanFiles_SkipVars
- Vars 使用 → WriteSkip + "uses templates or patches"
TestPlanFiles_SkipPatches
- Patches 使用 → WriteSkip
TestPlanFiles_SkipCreateOnly
- reconcile: create_only → WriteSkip
TestPlanFiles_SharedSourceWarning
- repositories が 2 以上 → Change.Warnings に "shared source" が含まれる
TestPlanFiles_NoDiff
- GitHub とローカルが同一 → Type == NoOp
TestReplaceContent_LiteralBlock
- content: | の中身が更新される
TestReplaceContent_PreservesComments
- YAML コメントが保持される
TestReplaceNode_SingleDoc
- 単一ドキュメントの spec 置換
TestReplaceNode_MultiDoc_TargetOnly
- multi-doc YAML で指定 docIndex のみ更新、他は不変
TestReplaceNode_InvalidPath
- 存在しないパス → error 返却
各エラーケースの期待動作を定義する。
| ケース | 期待動作 |
|---|---|
--into パスが存在しない |
manifest.ParseAll のエラーをそのまま返す(既存動作) |
--into パス内に owner/repo が見つからない(FindMatches が空) |
warning を出力してそのターゲットをスキップ、他のターゲットは続行 |
FetchRepository が 404 を返す |
warning "repository not found on GitHub: owner/repo" を出力してスキップ |
FetchRepository が 401/403 を返す |
エラーとして返す(認証問題は全ターゲットに影響するため続行不可) |
FetchRepository がネットワークエラー |
gh.Runner の既存リトライ(3回)に任せ、最終的に失敗したら warning でスキップ |
| multi-doc YAML で特定 doc のパッチ失敗 | エラーとして返す(部分適用は YAML を壊すリスクがあるため) |
yamledit.ReplaceNode がパス不一致 |
エラーとして返す(マニフェスト構造の前提が崩れている) |
yamledit.ReplaceContent がパス不一致 |
エラーとして返す |
| WriteSource のローカルファイルが読み取り専用 | os.WriteFile のエラーをそのまま返す |
PlanInto で一部ターゲット成功・一部失敗 |
成功分の plan は保持し、失敗分は warning として集約。全失敗の場合のみ error を返す |
Apply 中のファイル書き込み失敗 |
即座にエラーを返す(部分書き込みは YAML を壊すリスクがあるため) |
既存の repository.Change は apply 用の前向き表現(Children 階層、Create/Delete のセマンティクス)。
import 方向に ReverseChanges() で流用する設計は、branch protection / rulesets / actions の
階層的な Change 構造の逆変換で破綻する。
代わりに import 専用の planner を作り、フィールド単位で local effective spec vs GitHub imported spec を
比較し、FieldDiff(表示用)と YAML パッチ(適用用)を直接生成する。
repository.Change / ReverseChanges / HasRealChanges には一切依存しない。
full spec を $.repositories[N].spec に書くと defaults/override の分離が失われ、
DRY を壊し、defaults の将来変更が効かなくなる。
代わりに minimalOverride(defaults, imported) で defaults との差分のみ を再構成して書き戻す。
パーサーで DefaultsSpec と OriginalEntrySpec を RepositoryDocument に保持し、import 時に参照する。
diff 表示は OriginalEntrySpec → newOverride を見せることで、
「GitHub 実態に合わせる」と「manifest 構造を壊さない」の両方を満たす。
フィールドタイプごとのルール:
- scalar: defaults と同値なら zero value(
yaml:",omitempty"で消える)、違えば override - map-like (
features,merge_strategy): key 単位で defaults と違うものだけ残す - list (
topics等): defaults と同値なら omit、違えば丸ごと override - secrets: ローカル override を保持、import 対象外
- override が空なら spec 自体を書かない(defaults のみで十分)
GitHub API は secret の値を返さない(名前のみ)。ToManifest() が生成する spec で secrets の値は空になる。
spec 丸ごと書き戻すとローカルの secret 値が消失するため、
書き戻し前にローカルの secrets フィールドをそのまま保持する。
理由: Secrets はローカルが常に source of truth。GitHub → local の同期対象にならない。
Vars(テンプレート変数)や Patches を使った FileEntry は、GitHub 上のコンテンツがレンダリング/パッチ適用済み。
逆変換は一般的に不可能であり、書き戻すと同じ FileSet を共有する他リポジトリに波及するため、
WriteSkip + warning とする。
kind: File はパーサーが FileSet に変換する(単一リポジトリの FileSet として扱う)ため、
パース後は同一構造になり、書き戻し先の YAML パスも同じ。分離不要。
一方 Repository / RepositorySet は変換されず、書き戻し先が異なる($.spec vs $.repositories[N].spec)ため分離が必要。
DocumentInfo 方式(Kind/Owner/Name の文字列ベース)ではなく、
RepositoryDocument / FileDocument の型安全なラッパー方式を採用。
match 時のキャスト不要で、RepositorySet の SetEntryIndex / DefaultsSpec 等の追加メタデータも自然に持てる。
Importer 構造体のメソッドベースではなく、関数ベースの API を採用。 DI は引数で渡す。構造体が不要なほどシンプルで、テストも書きやすい。
レイヤー構成は既存アーキテクチャ(cmd → infra → domain)に合わせる。
infra.ImportInto() が runner/printer/tracker を構築し importer.PlanInto() を呼ぶ。
ドメインロジックは importer パッケージに閉じる。
同一マニフェストファイルに複数リポジトリが定義されている場合、
ターゲットごとに PlanInto を個別に呼ぶと manifestBytes が共有されず、
後のターゲットの Apply が先のターゲットのパッチを上書きしてしまう。
対策として、PlanInto は全ターゲットを1回で受け取り、manifestBytes を内部で共有する。
各ターゲットのパッチが逐次累積されるため、同一ファイルへの複数編集が正しく反映される。
source: ./shared.yaml を複数リポジトリで共有している場合(Vars/Patches なし)、
書き戻すと他 repo にも波及するが、これは期待通りの動作。
全リポに同一内容を配信しているので、ドリフトを取り込んで全リポに反映されるのが正しい。
Vars/Patches 付きの場合のみ WriteSkip(テンプレート逆変換が不可能なため)。
安全策: 共有ソース更新時は diff 表示に warning を出す。
PlanFiles で FileSet の spec.repositories が 2 以上の場合、
Change.Warnings に "shared source: affects N repositories" を追記し、
buildDiffEntries で diff UI に明示する。
Plan 後の確認は既存の ui.ConfirmWithDiff + ui.DiffEntry を再利用する。
repo FieldDiff は YAML diff テキストに変換して DiffEntry にマップ、
file changes はそのまま DiffEntry にマップ。
buildDiffEntries(plan) ヘルパーで IntoPlan → []ui.DiffEntry の変換を行う。
PoC: babarot/pull-command ブランチ