Skip to content

Instantly share code, notes, and snippets.

@babarot
Last active March 31, 2026 00:33
Show Gist options
  • Select an option

  • Save babarot/17e4d3b69b0f5e13bdc8bce5236895b2 to your computer and use it in GitHub Desktop.

Select an option

Save babarot/17e4d3b69b0f5e13bdc8bce5236895b2 to your computer and use it in GitHub Desktop.

import --into 設計(改訂版 v3)

コンセプト

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: ローカルファイル/マニフェストを更新

新パッケージ: internal/importer/

PoC (babarot/pull-command ブランチ) の設計を踏襲し、関数ベースの API とする。 既存の repository.Change は apply 用の前向き表現であり、import には流用しない。 import 専用の planner でフィールド単位比較 → YAML edit を直接生成する。

types.go

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)
}

files.go

// 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
}

match.go

// FindMatches は ParseResult から owner/repo に該当するリソースを探す。
// Repository と RepositorySet を分離して返す。
func FindMatches(parsed *manifest.ParseResult, fullName string) Matches

into.go — オーケストレーション

関数ベースの API。Importer 構造体は持たず、必要な依存は引数で渡す。 複数ターゲットを1回の呼び出しで処理し、manifestBytes を全ターゲット間で共有する。 これにより同一マニフェストファイル内の複数リポジトリへのパッチが正しく累積される。

// PlanInto は全ターゲットに対して変更計画を立てる。
// manifestBytes を内部で共有し、同一ファイルへの逐次パッチを正しく累積する。
func PlanInto(targets []TargetMatches, runner gh.Runner, printer ui.Printer, tracker *ui.RefreshTracker) (IntoPlan, error)

内部フロー(各ターゲットについて逐次実行):

  1. GitHub 状態を fetch(repository.FetchRepository)— ctx 付きシグネチャをそのまま使う
  2. ToManifest() で manifest.Repository に変換
  3. Repository マッチ → PlanRepository() でフィールド比較 + YAML パッチ
  4. RepositorySet マッチ → PlanRepositorySet() で最小 override 再構成 + YAML パッチ
  5. FileSet マッチ → PlanFiles() でファイル単位の diff

各ステップで manifestBytes を共有 map から読み書きするため、 先のターゲットのパッチ結果が後のターゲットに引き継がれる。

逐次実行制約: 同一マニフェストファイルへのパッチが累積する必要があるため、 ターゲット間のループは並列化してはならない。各ターゲットを逐次処理する。 (GitHub 状態の fetch は並列化可能だが、manifestBytes への書き込みは逐次。)

mutate スコープ: manifestBytesPlanInto 内でのみ生成・更新される。 PlanInto から返る IntoPlan.ManifestEdits は最終結果のスナップショットであり、 呼び出し元(cmd 層・infra 層)が manifestBytes を直接操作することはない。

repository.go — import 専用 planner

既存の 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.Secrets

RepositorySet の最小 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 のみで十分)。

パーサー拡張: RepositoryDocumentDefaultsSpecOriginalEntrySpec を追加し、 parseRepositorySet() で保持する(展開前の defaults と元の override spec を記録)。

diff 表示: OriginalEntrySpecnewOverride の差分をユーザーに見せる。 これにより「GitHub 実態に合わせる」と「manifest 構造を壊さない」の両方を満たす。

// PlanRepositorySet 内:
effectiveImported := imported.Spec
newOverride := minimalOverride(defaults.Spec, effectiveImported)
// diff 表示は OriginalEntrySpec → newOverride
// YAML patch は $.repositories[N].spec を newOverride に置換

files.go

// 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 → WriteSkip
  • Vars or Patches 使用 → 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
}

apply.go

// ApplyInto は計画に基づいてローカルファイル/マニフェストを更新する独立関数。
// IntoPlan はメソッドを持たず、表示用データのみ保持する。
// manifestEdits は PlanInto が生成した最終結果(path → 更新後バイト列)。
func ApplyInto(plan IntoPlan) error

新パッケージ: internal/yamledit/

goccy/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)

既存コードへの変更

internal/manifest/types.go - ParseResult 拡張

PoC に倣い、DocumentInfo 方式ではなく型安全なドキュメントラッパーを採用する。 ParseResult は破壊的に変更する(従来の field Repositories / FileSets は削除)。 既存コードとの互換のため Repositories() / FileSets() ヘルパーメソッドを提供する。 既存の全 call site(parsed.Repositoriesparsed.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 設定済み)
}

FileEntryOriginalSource string フィールドを追加(source 解決時にローカルファイルパスを記録)。

internal/manifest/parser.go - ドキュメントメタデータの記録

パーサーの具体的な変更箇所:

  • parseDocument(): 返り値を RepositoryDocument / FileDocument でラップする。 SourcePath(パース対象のファイルパス)と DocIndex(multi-doc YAML 内の 0-based 位置)はここで設定。
  • parseRepositorySet(): 展開した各 Repository に FromSet: trueSetEntryIndex: irepositories[] 内の位置)、 DefaultsSpec(展開前の defaults)、OriginalEntrySpec(merge 前の元の override spec)を付与。 DefaultsSpecminimalOverride() で使用し、OriginalEntrySpec は diff 表示で使用する。
  • ParseAll(): 各ドキュメントの結果を ParseResult.RepositoryDocs / ParseResult.FileDocs に追加。

internal/manifest/source.go - OriginalSource の設定

  • SourceResolver.ResolveFiles(): ローカルファイル(source: ./path/to/file)を解決する際に FileEntry.OriginalSource にローカルファイルパスをセット。 github:// スキームの場合はセットしない(WriteSkip 対象)。

cmd/import.go - フラグ追加・分岐

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 PlanRepositorySetminimalOverride() + 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

Phase 1 完了条件

  • ParseResult の型変更後、go build ./... が通る
  • 既存の全テスト(go test ./...)がパスする(既存 plan/apply パスが壊れていない)
  • FindMatches が Repository / RepositorySet / FileSet の各リソースに対して正しくマッチを返す
  • --into フラグ指定時に runImportInto に分岐する(--into なしは既存の runImport のまま)

Phase 2a 完了条件

  • PlanRepository が scalar (description, visibility, etc.) / list (topics) / nested map (features.*, merge_strategy.*, actions.*) の差分を正しく FieldDiff として返す
  • 差分なしの場合に空の RepoPlan を返す
  • yamledit.ReplaceNode を使って manifestBytes が更新される

Phase 2b 完了条件

  • minimalOverride が defaults と同値のフィールドを zero value にし、異なるフィールドのみ残す
  • 全フィールドが defaults と同値の場合、spec 自体が空(omitempty で消える)
  • PlanRepositorySet の diff 表示が OriginalEntrySpecnewOverride になっている
  • YAML パッチが $.repositories[N].spec のみを書き換え、defaults や他エントリを壊さない

Phase 2c 完了条件

  • branch_protection: pattern 単位でフィールド比較し、追加/変更/削除を FieldDiff に記録
  • rulesets: name 単位でフィールド比較(bypass_actors, conditions, rules 含む)
  • ローカルにない branch_protection/ruleset が GitHub にある場合は FieldDiff に記録(ユーザーが判断できるよう表示)

Phase 2d 完了条件

  • secrets: ローカル値がそのまま保持され、GitHub 側の secret 名リストとの差分は取らない
  • variables: GitHub API の値とローカル値を比較し、差分を FieldDiff に記録

Phase 3 完了条件

  • WriteSource: ローカルファイルパスに GitHub の内容が書き込まれる
  • WriteSkip: Vars/Patches 付き、github:// source、create_only の各条件で正しくスキップ + Reason 設定
  • 共有ソース(repositories が2以上)の場合に warning が Change.Warnings に追記される

Phase 4 完了条件

  • ReplaceContent がコメントを保持したまま literal block の内容を更新
  • ReplaceNode が multi-doc YAML の指定ドキュメントのみを更新し、他ドキュメントに影響しない
  • WriteInline の E2E: YAML 内の content: | ブロックが更新される

テスト方針

各フェーズは対応するテストが全パスすることを完了条件とする。 既存テスト(go test ./...)は全フェーズを通じて常にパスしなければならない。

Phase 1: internal/manifest/ + internal/importer/match.go

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 両方にマッチ → 両方のフィールドにセット

Phase 2a: internal/importer/repository.go (scalar/list/map)

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] が更新されている

Phase 2b: internal/importer/repository.go (RepositorySet)

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 のみを書き換え

Phase 2c: internal/importer/repository.go (complex fields)

TestPlanRepository_BranchProtection_Update
  - existing pattern のフィールド変更 → FieldDiff 記録
TestPlanRepository_BranchProtection_NewOnGitHub
  - ローカルにない pattern が GitHub にある → FieldDiff 記録
TestPlanRepository_Rulesets_Update
  - name 単位で enforcement/rules/conditions の変更検出
TestPlanRepository_Rulesets_BypassActors
  - bypass_actors の変更検出

Phase 2d: internal/importer/repository.go (secrets/variables)

TestPlanRepository_SecretsPreserved
  - Plan 後の manifestBytes 内で secrets がローカル値のまま
TestPlanRepository_VariablesDiff
  - variables の値が異なる → FieldDiff 記録
TestPlanRepository_VariablesNoDiff
  - variables が同値 → diff なし

Phase 3: internal/importer/files.go

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

Phase 4: internal/yamledit/

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 への依存を排除

既存の 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 には一切依存しない。

RepositorySet の最小 override 再構成

full spec を $.repositories[N].spec に書くと defaults/override の分離が失われ、 DRY を壊し、defaults の将来変更が効かなくなる。

代わりに minimalOverride(defaults, imported)defaults との差分のみ を再構成して書き戻す。 パーサーで DefaultsSpecOriginalEntrySpecRepositoryDocument に保持し、import 時に参照する。 diff 表示は OriginalEntrySpecnewOverride を見せることで、 「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 のみで十分)

Secrets の書き戻し

GitHub API は secret の値を返さない(名前のみ)。ToManifest() が生成する spec で secrets の値は空になる。 spec 丸ごと書き戻すとローカルの secret 値が消失するため、 書き戻し前にローカルの secrets フィールドをそのまま保持する

理由: Secrets はローカルが常に source of truth。GitHub → local の同期対象にならない。

テンプレート/パッチ付きファイル

Vars(テンプレート変数)や Patches を使った FileEntry は、GitHub 上のコンテンツがレンダリング/パッチ適用済み。 逆変換は一般的に不可能であり、書き戻すと同じ FileSet を共有する他リポジトリに波及するため、 WriteSkip + warning とする

Matches で File/FileSet を分離しない理由

kind: File はパーサーが FileSet に変換する(単一リポジトリの FileSet として扱う)ため、 パース後は同一構造になり、書き戻し先の YAML パスも同じ。分離不要。 一方 Repository / RepositorySet は変換されず、書き戻し先が異なる($.spec vs $.repositories[N].spec)ため分離が必要。

ParseResult の設計

DocumentInfo 方式(Kind/Owner/Name の文字列ベース)ではなく、 RepositoryDocument / FileDocument の型安全なラッパー方式を採用。 match 時のキャスト不要で、RepositorySet の SetEntryIndex / DefaultsSpec 等の追加メタデータも自然に持てる。

API スタイルとレイヤー構成

Importer 構造体のメソッドベースではなく、関数ベースの API を採用。 DI は引数で渡す。構造体が不要なほどシンプルで、テストも書きやすい。

レイヤー構成は既存アーキテクチャ(cmd → infra → domain)に合わせる。 infra.ImportInto() が runner/printer/tracker を構築し importer.PlanInto() を呼ぶ。 ドメインロジックは importer パッケージに閉じる。

複数ターゲットと manifestBytes の共有

同一マニフェストファイルに複数リポジトリが定義されている場合、 ターゲットごとに PlanInto を個別に呼ぶと manifestBytes が共有されず、 後のターゲットの Apply が先のターゲットのパッチを上書きしてしまう。 対策として、PlanInto は全ターゲットを1回で受け取り、manifestBytes を内部で共有する。 各ターゲットのパッチが逐次累積されるため、同一ファイルへの複数編集が正しく反映される。

共有ソースファイルへの WriteSource は許可(warning 付き)

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 に明示する。

diff 表示

Plan 後の確認は既存の ui.ConfirmWithDiff + ui.DiffEntry を再利用する。 repo FieldDiff は YAML diff テキストに変換して DiffEntry にマップ、 file changes はそのまま DiffEntry にマップ。 buildDiffEntries(plan) ヘルパーで IntoPlan → []ui.DiffEntry の変換を行う。

参考実装

PoC: babarot/pull-command ブランチ

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment