diff --git a/.github/skills/ghpmv-e2e-validation/SKILL.md b/.github/skills/ghpmv-e2e-validation/SKILL.md index 2148ece8..ed56e7b1 100644 --- a/.github/skills/ghpmv-e2e-validation/SKILL.md +++ b/.github/skills/ghpmv-e2e-validation/SKILL.md @@ -185,6 +185,8 @@ agent が terminal に command を直接入力できず、ユーザー自身が Step 1の質問を始める前に、`GHPMV_E2E_SETTINGS`が設定されている場合は、そのpathだけをauthoritativeなJSONCとして読む。明示pathが存在しない、読み取れない、またはvalidationに失敗した場合は、local/shared fileへfallbackせず、pathとエラーを示して修正されるまで停止する。`GHPMV_E2E_SETTINGS`が未設定の場合だけ、`tests/e2e.settings.local.jsonc`、`tests/e2e.settings.jsonc`の順で最初に存在するfileを読む。`//`コメントと末尾commaを許可する。設定値は次の用途に使い、同じ非secret値を再質問しない。 +自動検出ではglobやtracked-file一覧だけに依存しない。`tests/e2e.settings.local.jsonc`はgitignore対象のため、file search結果に現れないことがある。必ずrepository rootから`Test-Path -LiteralPath tests/e2e.settings.local.jsonc`でexact pathの存在を先に確認し、存在すればshared fileを読む前にlocal fileを読む。local fileが存在するのにshared fileを先に読んだり、localに非空で設定されたlogin、organization、repository、policy confirmationを再質問したりしてはならない。 + - source / target Organization、API / Web / uploads URL、browser profile - Integration / Browser fixtureのProject番号とsource / target repository - source / target browser login、collaborator login、EMUを含むuser mapping @@ -194,6 +196,8 @@ Step 1の質問を始める前に、`GHPMV_E2E_SETTINGS`が設定されている 自動検出したlocal/shared fileでは、空文字、存在しないlocal resource、現在のhostと矛盾するURL、またはschema validationに失敗する値を確定値として扱わず、その項目だけを通常どおり質問する。明示指定した`GHPMV_E2E_SETTINGS`のエラーだけはfallbackや質問による補完をせず停止する。JSONCにはPAT値、cookie、browser storage-state内容を保存させない。`tokenEnvironmentVariable`などの値は環境変数名であり、secretそのものではない。 +`browser-e2e`で`users.sourceBrowserLogin`と`users.targetBrowserLogin`が両方とも非空なら、source / target browser accountが同一か別かを質問しない。account identityは正規化した`webBaseUrl` hostとbrowser loginの組で機械判定する。同じhostでloginがcase-insensitiveに一致する場合だけ同一account、hostまたはloginが異なる場合は別accountとして記録する。どちらかのloginが空の場合だけ不足しているloginを一件ずつ質問し、両方確定後に同じ規則で判定する。 + `source.apiBaseUrl` / `target.apiBaseUrl`はghpmv用GraphQL endpointで、`https://api.TENANT.ghe.com/graphql`またはtenant API originのどちらも受け付ける。GEIの`--github-source-api-url` / `--target-api-url`へ渡す値は別に導出し、末尾のoptional `/graphql`とtrailing slashを除いた`https://api.TENANT.ghe.com` originを使う。GraphQL endpointをそのままGEI argumentへ再利用しない。 settings由来のOrganization loginは`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`、repository short nameは`^(?!\.{1,2}$)[A-Za-z0-9._-]{1,100}$`、user loginは`^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,98}[A-Za-z0-9])?$`で検証する。これらを含むsettings由来の文字列をPowerShell commandへ渡す場合は、profileだけでなくOrganization、repository、login、URL、title、path、mapping値をすべてsingle-quoted argumentにする。値のpatternがsingle quoteを許可する項目では、既存の規則どおり`'`を`''`へ置換してから囲む。検証済みであってもunquoted substitutionは行わない。 @@ -251,7 +255,7 @@ GEI roleは`GHPMV_GEI_SOURCE_TOKEN` / `GHPMV_GEI_TARGET_TOKEN`にだけ適用す 1. source host type: **GitHub.com(通常の GHEC を含む)** または **GHEC with data residency (`*.ghe.com`)** 2. `api-only` / `browser-e2e` では target host type も同じ二択で確認する。 3. data residency を選んだ側ごとに、placeholder ではない tenant web URL (`https://TENANT.ghe.com`) を自由入力の質問カードで確認する。対応する API URL (`https://api.TENANT.ghe.com`) を導出して別の確認カードで提示し、確定する。target側ではuploads URL (`https://uploads.TENANT.ghe.com`) も導出して別の確認カードで確定する。 -4. `browser-e2e` では source / target の browser account が同一か別かを host とは別の質問で確認する。 +4. `browser-e2e` では、settingsのbrowser loginが片側でも空の場合だけ不足loginを確認する。両login確定後は`(web host, login)`で同一/別accountを機械判定し、同一か別かを別質問で確認しない。 GitHub.com は web URL `https://github.com`、API URL `https://api.github.com/graphql` として記録する。特に **GitHub.com source → GHEC with data residency target** を `github.com-to-ghec-dr` として一級シナリオにする。この topology では source command は既定の GitHub.com endpoint を使い、target command と target browser profile だけに tenant endpoint を指定する。host が異なる場合は login 文字列が似ていても `source` / `target` browser profile と token を必ず分ける。 diff --git a/.gitignore b/.gitignore index 3ca2c175..45f7247d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ # Local manual migration test artifacts .manual-test/ +# Local test-generation agent artifacts +.testagent/ + # Local E2E settings may contain organization, repository, and user names. tests/e2e.settings.local.jsonc diff --git a/docs/BROWSER_AUTOMATION_PLAN.md b/docs/BROWSER_AUTOMATION_PLAN.md index 59834f49..649ce4b8 100644 --- a/docs/BROWSER_AUTOMATION_PLAN.md +++ b/docs/BROWSER_AUTOMATION_PLAN.md @@ -27,7 +27,7 @@ GraphQL と Playwright を組み合わせた View・Workflow 移行の詳細設 | **Slice by** | ❌ API に無い → **UI で読む** | **UI** | | | **Field sum** | ❌ API に無い → **UI で読む** | **UI** | | | **Roadmap 設定(Dates / Zoom / Markers)** | ❌ API に無い → **UI で読む** | **UI** | | -| タブの並び順 | GraphQL `views`(orderBy: POSITION) | **UI**(タブの drag & drop)| v1 では省略可(§8) | +| タブの並び順 | **UI**(`navigation "Select view"` 内のsaved tab `href`順) | **UI**(タブの drag & drop) | GraphQL `POSITION`は現行UIのsaved-tab順と乖離する場合がある。`ViewSnapshot.tabPosition`はschema v1のnullable additive field | ### Workflow のプロパティ別ソースマップ @@ -143,11 +143,11 @@ internal static class Sel | V-7 | Roadmap | Dates(date フィールド対 or iteration)、Zoom(Month/Quarter/Year)、Markers | | V-8 | 全レイアウト共通 | filter 文字列(そのまま転記。フィールド名は移行済み前提で互換) | | V-9 | 全レイアウト共通 | Slice by | -| V-10 | View の name / タブ並び順 | 並び順は v1 スコープ外(§8) | +| V-10 | View の name / タブ並び順 | browser-assisted export/verifyでDOM `href`順を読み、browser-assisted importで最小D&Dを適用 | -### 3.2 export: UI からの読み取り手順(API に無い 3 項目のみ) +### 3.2 export: UI からの読み取り手順(APIで正しく取得できない4項目) -対象: Slice by / Field sum / Roadmap 設定。GraphQL export の後、view ごとに 1 回だけページを開いて補完する。 +対象: saved-tab order / Slice by / Field sum / Roadmap設定。GraphQL exportの後、viewごとに1回だけページを開いて補完し、最後にtab stripのDOM順を取得する。 ``` 手順(view ごと): @@ -158,6 +158,7 @@ internal static class Sel - "Field sum: " → `ViewUiSnapshot.FieldSum` - Roadmap のみ: "Dates: <...>", "Zoom level: ", "Markers: <...>" 4. Esc でメニューを閉じる +5. `navigation "Select view"`内のsaved tab `href`をDOM順に列挙し、View numberへ変換して`tabPosition`を付与する ``` 実装メモ: メニュー項目は「設定名 + 現在値」を accessible name に含むため、label prefix で特定する。複数選択項目は overlay の `aria-checked` を読む。 @@ -176,12 +177,13 @@ EnrichView(spec, targetViewNumber): 6. Field sum: ViewOptions → "Field sum" → spec.FieldSum の各フィールドをチェック 7. Roadmap のみ: "Dates" → 開始/終了フィールド対 or iteration を選択、"Zoom level"、"Markers" のチェック群 8. 保存: View menu → "Save view" → alertdialog の "Save"(dialog が出ない UI variant では直接保存) - 9. 検証は後続の `ghpmv verify --enable-browser-automation` または browser E2E で行う + 9. 全 View 設定の適用後、target のDOM `href`順と snapshot順から最小移動計画を作り、必要なタブだけdrag-and-drop + 10. 検証は後続の `ghpmv verify --enable-browser-automation` または browser E2E で行う ``` Project conflict は API View stage より前に `--on-conflict skip|update|fail` または `--project-number` で解決する。API importer は source view number と target view number の対応を返し、browser importer はその View に未公開設定だけを適用する。 -作成順序: **スナップショットの view number 昇順**で作成(タブ順が概ね再現される)。デフォルトで作られる "View 1" は、スナップショット先頭の view で上書き(rename + 設定)して消費する。 +作成順序: browserで取得した`tabPosition`があるsnapshotはその昇順、未取得のsnapshotはview number昇順で作成する。デフォルトで作られる"View 1"はsnapshotの先頭Viewで上書き(rename + 設定)して消費する。API-only importはtab orderを適用できない旨をwarningにし、browser-assisted importはView設定適用後にtargetのDOM順を読み取って修復する。 --- @@ -251,7 +253,7 @@ ApplyWorkflow(spec): ```jsonc { "views": [{ - "number": 1, "name": "Backlog", "layout": "TABLE_LAYOUT", + "number": 1, "tabPosition": 0, "name": "Backlog", "layout": "TABLE_LAYOUT", "filter": "is:issue -status:Done", "visibleFields": ["Title", "Assignees", "Status", "Priority"], // 列順そのまま "groupBy": ["Status"], "verticalGroupBy": [], @@ -282,8 +284,8 @@ ApplyWorkflow(spec): browser importer 自体は各 view / workflow の適用直後に完全な read-back diff を行わない。移行後は `ghpmv verify --enable-browser-automation` が次を比較する: -1. **API で読める項目**: GraphQL で対象 view を `views(first:50)` から number 一致で取得し、`layout / filter / groupByFields / sortByFields / verticalGroupByFields / fields(POSITION順)` を spec と比較 -2. **UI でしか読めない項目**: §3.2 / §4.2 の export 用読み取りルーチンを**そのまま再利用**してターゲットを再スクレイプし、spec.ui と比較 +1. **API で読める項目**: GraphQLで対象viewをnumber一致で取得し、`layout / filter / groupByFields / sortByFields / verticalGroupByFields / visibleFields`をspecと比較 +2. **UI でしか読めない項目**: saved-tab DOM順と§3.2 / §4.2のexport用読み取りルーチンを**そのまま再利用**してターゲットを再スクレイプし、`tabPosition`と`spec.ui`を比較 3. 差分は `verify` コマンドと同じレポーター(期待値/実測値/対象)で出力 手動実行する `BrowserRoundTripTests` は View と Workflow のラウンドトリップを別々のテストに分け、それぞれ @@ -312,11 +314,13 @@ v1 対象外項目の将来対応方針(v1.x / v2)は [PLAN.md §8「スコー | 項目 | 判断 | |---|---| | 表示フィールドの列順 | GraphQL `visibleFields` / `visibleFieldIds` で明示的に再現する | -| View タブの並び順(D&D のみ) | v1 スコープ外。import 後に警告で「手動で並び替えてください」と案内 | +| View タブの並び順(D&D のみ) | 対応済み。LIS を残す最小 D&D 計画を使い、overflow tab は `ScrollIntoViewIfNeededAsync` 後に操作。既に一致する場合は drag しない | | disabled workflow への設定適用 | 対応済み。設定保存後に toggle off へ戻す | | memex 内部 API の直接利用 | 既定では不採用。HAR は現時点で成果物として記録していない。UI 操作不能項目が出た場合に調査・取得を検討 | | UI 変更による破損 | リリース前の手動 browser E2E と `docs/ui-maps/` の実測記録で確認。回復可能な破損は warning + 対象設定の skip。scheduled/nightly CI は未実装 | +`ViewUiExporter.GraphQlPositionMatchesDomOrder`は、APIのPOSITION順とDOM saved-tab順の一致状態をprojectごとに記録する。新規targetの作成順などによって偶然一致する場合があるため、通常exportでは一致をAPI復旧noticeとして扱わない。Browser round-trip E2Eは、DOM順とGraphQL順が異なることを確認済みの非自明source fixtureに限って現時点の乖離をcapability canaryとしてassertする。そのsourceで一致した場合、E2Eを意図的に失敗させ、複数fixtureで再確認したうえでbrowser readをGraphQL readへ置換できるか再評価する。public mutationにtab-order write inputが追加されたかは別途GraphQL schema/changelogで確認する。 + ## 9. 実装タスク分解と現在の状態 | ID | タスク | 状態 | @@ -331,3 +335,4 @@ v1 対象外項目の将来対応方針(v1.x / v2)は [PLAN.md §8「スコー | B7 | Workflow import(§4.3)W-1〜W-8 | 完了 | | B8 | Workflow import W-9(Auto-add 複数 + 上限処理) | 完了。実装上限は 20 | | B9 | ラウンドトリップ E2E(§6) | テスト実装済み・手動実行。scheduled/nightly CI は未実装 | +| B10 | View tab DOM-order export / verify + browser D&D import | 完了。API-only / 旧snapshotのnullは比較・修復対象外 | diff --git a/docs/MANUAL_TEST_PLAN.md b/docs/MANUAL_TEST_PLAN.md index 250fd279..860e18ce 100644 --- a/docs/MANUAL_TEST_PLAN.md +++ b/docs/MANUAL_TEST_PLAN.md @@ -45,7 +45,7 @@ GitHub Copilot に一問一答で案内させる場合は、repository-local Ski - linked repository - linked Team と Team に付与される read permission - explicit project collaborators - - Table / Board / Roadmap views + - Table / Board / Roadmap views と tab order - enabled / disabled workflows と Auto-add settings --- @@ -66,7 +66,7 @@ GitHub Copilot に一問一答で案内させる場合は、repository-local Ski | Linked repositories | `ghpmv verify` warning 確認 + 目視 | `--repo-mapping` が必須。 | | Project-to-Team links | `ghpmv verify` + Team Projects / Manage access UI | `organization/slug` で識別。explicit collaborator とは別カテゴリ。 | | Explicit project collaborators | browser export/import + 目視 | inherited access は対象外。 | -| Views | browser export/import + 目視 | Table / Board / Roadmap、filter、sort、slice、field sum など。 | +| Views | GraphQL + browser export/import/verify + 目視 | Table / Board / Roadmap、filter、sort、slice、field sum、tab order。saved-tab orderのread/verifyにはbrowser automationが必要。 | | Workflows | browser export/import + 目視 | built-in workflows、Auto-add、disabled workflow。 | ### 2.2 対象外または warning 許容 @@ -75,7 +75,6 @@ GitHub Copilot に一問一答で案内させる場合は、repository-local Ski - Draft issue の元作成者 / 作成日時の完全保持。 - item / field value の履歴。 - inherited / base-role / org owner / enterprise policy 由来の project access。 -- View tab の drag-and-drop 順序。 - Insights charts。 - REDACTED / 権限不足で見えない items。 - archived item の position。 @@ -126,7 +125,7 @@ EMU / SAML / OIDC backed organization の場合は、PAT と browser session の Team link の手動 E2E では共有 Team を変更せず、source/target の各 organization にこのテスト専用 Team を作成してください。source fixture には `--fixture-team ` を渡します。target Team は同じ slug、または renamed mapping を確認する別 slug にします。 -Views の作成と name / layout / filter / visible fields は GraphQL API で設定します。標準 fixture には API 未対応の View 設定と Workflows も含まれるため、`ghpmv setup --fixture-ui` は API View import の後に C# の Playwright layer でそれらだけを補完します。手動で UI をぽちぽち濃くする必要はありません。 +Views の作成と name / layout / filter / visible fields は GraphQL API で設定します。標準 fixture には API 未対応の View 設定、非自明な `Fixture Roadmap → View 1 → Fixture Board` の tab order、Workflows も含まれるため、`ghpmv setup --fixture-ui` は API View import の後に C# の Playwright layer で補完します。手動で UI をぽちぽち濃くする必要はありません。 --- @@ -199,7 +198,7 @@ Get-Content .env | Where-Object { $_ -and $_ -notmatch '^\s*#' } | ForEach-Objec |---|---|---| | 通常の Project 移行 | `export` / `import` / `verify` | README の [Token permissions](../README.md#token-permissions) にある command 別の最小権限。 | | API-backed test fixture 作成 | `setup --fixture` | 下記の fine-grained PAT、または classic PAT。 | -| 実 API integration suite | `dotnet test tests/Ghpmv.Integration.Tests` | 上記 fixture 権限に加え、token owner が source / target 両 organization で disposable Team を作成・削除できること。REST Team API 用に classic PAT は `admin:org`、fine-grained PAT は **Organization permissions → Members: Read and write** が必要。suite は両 organization に1つの `GHPMV_TEST_TOKEN` を使うため、通常の cross-organization 構成では両方を操作できる classic PAT を使う。 | +| 実 API integration suite | `dotnet test tests/Ghpmv.Integration.Tests/Ghpmv.Integration.Tests.csproj` | 上記 fixture 権限に加え、token owner が source / target 両 organization で disposable Team を作成・削除できること。REST Team API 用に classic PAT は `admin:org`、fine-grained PAT は **Organization permissions → Members: Read and write** が必要。suite は両 organization に1つの`GHPMV_TEST_TOKEN`を使うため、通常のcross-organization構成では両方を操作できるclassic PATを使う。 | | UI-only fixture 作成 | `setup --fixture-ui` | Project API を読める token と、同じユーザーで保存した browser profile。 | | GEI source | `gh gei migrate-repo --github-source-pat` | 下記の GEI source role / classic PAT scope。 | | GEI destination | `gh gei migrate-repo --github-target-pat` | 下記の GEI destination role / classic PAT scope。 | @@ -308,7 +307,7 @@ Source project number: ### 5.2 View / Workflow fixture を GraphQL API + C# / Playwright で作成する -`ghpmv setup --fixture` は repository / fields / items / Status Updates までを作ります。`--fixture-require-new` を指定した新規 E2E fixture では、Project の Date / Iteration 値を実行週の月曜日を基準に配置し、Roadmap の初期表示範囲内で確認できるようにします。基準日は operation log に保存されるため、日をまたいだ再実行でも変わりません。続けて `ghpmv setup --fixture-ui` を実行すると、Views の基本設定を GraphQL API で作成・更新し、group/sort/slice/roadmap など API 未対応設定と Workflows を C# の `ViewUiImporter` / `WorkflowUiImporter` が Playwright で補完します。 +`ghpmv setup --fixture` は repository / fields / items / Status Updates までを作ります。`--fixture-require-new` を指定した新規 E2E fixture では、Project の Date / Iteration 値を実行週の月曜日を基準に配置し、Roadmap の初期表示範囲内で確認できるようにします。基準日は operation log に保存されるため、日をまたいだ再実行でも変わりません。続けて `ghpmv setup --fixture-ui` を実行すると、Views の基本設定を GraphQL API で作成・更新し、group/sort/slice/roadmap、非自明な tab order、Workflows を C# の `ViewUiImporter` / `WorkflowUiImporter` が Playwright で補完します。 ```powershell dotnet run --project src/Ghpmv.Cli -- setup ` @@ -343,6 +342,7 @@ dotnet run --project src/Ghpmv.Cli -- setup ` - `View 1`: Table、filter、sort、Slice by、visible fields - `Fixture Board`: Board、Column by、Swimlanes、Field sum - `Fixture Roadmap`: Roadmap、date fields、Quarter zoom、markers + - tab order: `Fixture Roadmap` → `View 1` → `Fixture Board` - Workflows - item state 系 built-in workflows - `Auto-add to project` @@ -359,6 +359,7 @@ dotnet run --project src/Ghpmv.Cli -- setup ` Views: +- タブを `Fixture Roadmap` → Table view → Board view の非自明な順に並べる - Table view - filter を設定 - hidden / visible fields を調整 @@ -486,6 +487,7 @@ dotnet run --project src/Ghpmv.Cli -- export ` - `repository-mappings.csv` が生成される。 - linked Team がある場合は `team-mappings.csv` が生成され、source 値は `organization/slug` になっている。 - source UI の Views / Workflows / collaborators に関する warning がない、または想定内である。 +- 各 View の `tabPosition` が 0 から始まる source UI 順で保存され、view `number` 順とは独立している。 ### 7.2 Mapping CSV を補完 @@ -587,6 +589,8 @@ dotnet run --project src/Ghpmv.Cli -- verify ` `--enable-browser-automation` を付けた verify は、比較前に target の View / Workflow UI 設定と explicit collaborators を再取得します。選択した profile が target host に未認証、または API token と別アカウントの場合は、target の読み取り開始前に明確なエラーと非ゼロ終了になります。 +同じverifyを先に`--enable-browser-automation` / `--browser-profile`なしでも実行し、API-readableなView設定を確認します。API-only verifyではsaved-tab orderとUI-only設定は`View` categoryの`NotVerified`として扱われます。続くbrowser-assisted verifyでDOM tab orderを含む完全一致を確認します。 + source / target の repository 名、user login、または Team slug が異なる場合、`verify` にも import と同じ `--repo-mapping` / `--user-mapping` / `--team-mapping` を渡してください。これにより Issue / PR item、linked repository、explicit user collaborator、linked Team は target 側の名前へ正規化して比較されます。生成されていない optional mapping の引数は外してください。 期待値: @@ -648,7 +652,9 @@ warning / error が出た場合は、次の観点で切り分けます。 - [ ] Board view の Column by / Swimlanes / Slice by が一致。 - [ ] Roadmap view の date fields / zoom / markers が一致。 - [ ] View 名が一致。 -- [ ] View tab order は v1 対象外として warning または手動補正対象に記録。 +- [ ] View tab order が `Fixture Roadmap` → `View 1` → `Fixture Board` で一致。 +- [ ] 通常幅とタブが画面幅を超える狭い幅の両方で source/target 順が一致。 +- [ ] import を再実行しても既に正しい tab order は変化しない。 ### 8.5 Status Updates @@ -693,7 +699,7 @@ warning / error が出た場合は、次の観点で切り分けます。 | ID | 手順 | 期待結果 | |---|---|---| -| N-1 | `--enable-browser-automation` なしで export/import | API-only 項目は移行され、Views / Workflows UI-only 項目は warning または未移行として扱われる。 | +| N-1 | `--enable-browser-automation` なしで export/import | API-only exportは`tabPosition`を省略し、読み込み時はnull(未取得)として扱う。browserで取得済みのtab orderを持つsnapshotのAPI-only importは未適用warning、API-only verifyは`NotVerified`として扱う。Views / Workflows UI-only項目もwarningまたは未移行として扱われる。 | | N-2 | `repository-mappings.csv` から fixture repo 行を削除して import | Issue / PR item が warning + skip され、Draft items は作成される。 | | N-3 | target token を source token に差し替えて import | 権限不足で失敗し、Project を壊さない。 | | N-4 | browser profile を間違える | ログイン / 権限エラーで失敗し、再ログイン案内が出る。 | @@ -703,7 +709,8 @@ warning / error が出た場合は、次の観点で切り分けます。 | N-8 | `team-mappings.csv` を存在しない Team に向けて import | Project 作成・metadata 更新より前に `unresolved` preflight error で停止する。 | | N-9 | Team read/maintainer 権限のない token、または admin access のない既存 Project で import | Team mutation の実行前に `permission` preflight error で停止する。 | | N-10 | target にだけ別の Team link を追加して verify | `TeamLink` warning と `PartialMatch` になり、target-only link は削除されない。 | -| N-11 | `project.template: true` の snapshot を `--owner-type user` で import | 最初の API write より前に、user-owned Project は template にできないことを示す error で停止する。 | +| N-11 | target UI で View tab を逆順にして browser-assisted verify | `View` categoryとJSON reportに`view tab order mismatch`が出る。続けてbrowser-assisted importを再実行すると順序が修復される。 | +| N-12 | `project.template: true` の snapshot を `--owner-type user` で import | 最初の API write より前に、user-owned Project は template にできないことを示す error で停止する。 | --- diff --git a/docs/MIGRATION_SCOPE.md b/docs/MIGRATION_SCOPE.md index fb22d1b9..6151e674 100644 --- a/docs/MIGRATION_SCOPE.md +++ b/docs/MIGRATION_SCOPE.md @@ -65,7 +65,7 @@ View names, layouts, filters, and ordered visible fields are imported through th | Roadmap views | ✅ | Date fields, zoom level and markers are tested. | | View API settings | ✅ | Name, layout, filter, and ordered visible fields are migrated without browser automation. | | View UI-only settings | ✅ | Grouping, sorting, slicing, field sums, and Roadmap settings are exported/imported by browser automation where the UI exposes them. | -| View tab order | ❌ | Views are recreated, but tab drag-and-drop ordering is not reproduced in v1. | +| View tab order | ✅ with browser automation | The public GraphQL `POSITION` order can differ from the saved-tab order shown by GitHub. Browser-assisted export/verify read tab `href` values in DOM order, and browser-assisted import applies the minimum drag-and-drop moves after all View settings. API-only export leaves tab order uncaptured, API-only import warns when a snapshot contains it, and API-only verify marks it not verified. | | Insights charts | ❌ | Out of scope for v1. They require a separate UI automation design. | ## Workflows @@ -85,7 +85,7 @@ Workflows require `--enable-browser-automation` because GitHub has no public API | Area | Supported? | Notes | |---|---:|---| -| `ghpmv verify` | ✅ | Compares the target project against the snapshot. GraphQL View settings are always checked; `--enable-browser-automation` re-reads UI-only View / Workflow settings and explicit collaborators. Supports category statuses, warning exit policy, and JSON reports. | +| `ghpmv verify` | ✅ | Compares the target project against the snapshot. GraphQL-readable View settings are always checked; `--enable-browser-automation` additionally re-reads saved-tab order, UI-only View / Workflow settings, and explicit collaborators. Supports category statuses, warning exit policy, and JSON reports. | | Resume after interruption | ✅ | Item and status-update import write target node IDs to `import-log.json` immediately. Reruns use those IDs rather than content matching, so legitimate repeated status bodies are preserved without duplication. An ambiguous Status Update create that did not persist its target ID keeps durable pending state and requires manual reconciliation instead of claiming a body/status/date match. | | Mapping CSV templates | ✅ | `export` writes repository, organization, Team, and user mapping templates without overwriting existing files. Team rows use `organization/slug` on both sides. | | Bulk export | ✅ | Omit `--project` to export every project owned by the organization/user into `//`. | @@ -113,7 +113,6 @@ Pass the same repository, user, organization, and Team mappings to `ghpmv verify | Original author and creation timestamp for draft issues | GitHub always attributes newly-created draft issues to the importing user. | `ghpmv` prepends a note with the original metadata. | | Item history / field history | GitHub has no API to recreate historical field changes. | Only the current state is migrated. | | Exporting inherited/base-role project access | GitHub separates explicit project collaborators from inherited access. | Handle inherited access through GEI and the target organization/team/repository/enterprise policy model. | -| View tab drag-and-drop order | UI-only and fragile; not part of v1. | Reorder tabs manually if the order matters. | | Insights charts | No public API; UI is more complex than Views/Workflows. | Future v2 candidate. | | Redacted items | The exporting account cannot see them. | Export with an account that has access to all project content. | @@ -122,7 +121,7 @@ Pass the same repository, user, organization, and Team mappings to `ghpmv verify - The module is opt-in and uses your own interactive session stored locally in `%APPDATA%/ghpmv/browser-state*.json`. Nothing is sent anywhere except to GitHub itself. - Automating the web UI is not covered by the public API's stability guarantees. You are responsible for using it consistently with the [GitHub Terms of Service](https://docs.github.com/site-policy/github-terms/github-terms-of-service); `ghpmv` performs low-rate, human-scale, sequential operations against your own Projects and does not scrape other users' data. - UI selectors can break when GitHub updates the Projects UI. Recoverable failures are warnings, and browser writes are not transactional. Always run browser-assisted `ghpmv verify` afterward. -- View tab order is not reproduced. +- View tab ordering is a recoverable, non-transactional browser write. A failed drag leaves any successful earlier moves in place and emits a warning; rerun browser-assisted import or reorder manually, then verify. ### Auto-add workflow limits per plan diff --git a/docs/ui-maps/projects-ui-discovery.md b/docs/ui-maps/projects-ui-discovery.md index 7c163bc9..1e61014c 100644 --- a/docs/ui-maps/projects-ui-discovery.md +++ b/docs/ui-maps/projects-ui-discovery.md @@ -7,6 +7,7 @@ | 操作 | セレクター | 備考 | |---|---|---| | View タブ | `getByRole('tab', { name: })` | tablist は `navigation "Select view"` 内 | +| View タブ並べ替え | `[role=tab][href$='/views/{number}']` の source を target の左右端へ drag-and-drop | View number で前方一致名を避ける。overflow 時は両 locator を `ScrollIntoViewIfNeededAsync` してから操作 | | 新規 View 作成 | `getByRole('tab', { name: 'New view' })` → menu `New view` → `menuitem "Table"/"Board"/"Roadmap"` | 選択と同時に view 作成・遷移(保存不要) | | View リネーム | タブをダブルクリック → `getByRole('textbox', { name: 'Change view name' })` → fill → Enter | 即時保存 | | View 設定メニュー | フィルターバーの `button "View"`(exact)| `menu > group "Configuration"` に `menuitem "Group by: " / "Markers: " / "Sort by: " / "Dates: " / "Zoom level: " / "Slice by: "`。**ラベルと現在値が name に結合**されるため部分一致(`name: /^Group by:/` 等)で特定する | @@ -24,11 +25,12 @@ 3. **Auto-add workflow は org にリポジトリが 1 つ以上ないと設定不可**("No repositories found")。import 側で前提チェックが必要 4. Workflow 閲覧モードでも設定値(フィルター文字列・対象リポジトリ・Set value)が DOM に出る → **Edit を押さずに UI-export 可能** 5. View 系の設定変更は SPA 内で「Unsaved changes」になり、明示保存が必要(タブ名変更は例外で即時保存) -6. GraphQL read-back: views { number name layout } / workflows { number name enabled } は UI 操作直後に反映される(遅延なし) +6. GraphQL read-backのView内容とWorkflow状態はUI操作後に反映されるが、`views(orderBy:{field:POSITION})`はsaved-tab DOM順と一致しない場合がある。実環境診断(2026-08-19)ではDOMが`Fixture Roadmap → View 1 → Fixture Board`、GraphQL POSITIONが`View 1 → Fixture Roadmap → Fixture Board`のまま60秒超乖離した。tab orderは`navigation "Select view"`内のsaved tab `href` DOM順を正とする ## フィクスチャー最終状態(gpm-source/projects/3) - Views: + - tab order=Fixture Roadmap → View 1 → Fixture Board - 1=View 1 (TABLE): filter=`status:Todo`, Sort by=Fixture Number (asc), Slice by=Fixture Select, visibleFields=既定 5 + Fixture Text + Fixture Date(Fixture Number はソート由来の仮想列のため visibleFields に入らない — 下記 E2E 知見 8) - 2=Fixture Board (BOARD): Column by=Fixture Select, Swimlanes=Status(GraphQL groupByFields に反映), Field sum=`Fixture Number` (Count は uncheck 済み) - 3=Fixture Roadmap (ROADMAP): Dates=Fixture Date → Fixture Sprint end, Zoom=Quarter, Markers=[Fixture Date] diff --git a/src/Ghpmv.Core/Browser/FixtureUiSnapshotFactory.cs b/src/Ghpmv.Core/Browser/FixtureUiSnapshotFactory.cs index 9b54e1b9..060c082b 100644 --- a/src/Ghpmv.Core/Browser/FixtureUiSnapshotFactory.cs +++ b/src/Ghpmv.Core/Browser/FixtureUiSnapshotFactory.cs @@ -60,6 +60,7 @@ private static IReadOnlyList CreateViews() => new ViewSnapshot { Number = 1, + TabPosition = 1, Name = "View 1", Layout = "TABLE_LAYOUT", Filter = "status:Todo", @@ -75,6 +76,7 @@ private static IReadOnlyList CreateViews() => new ViewSnapshot { Number = 2, + TabPosition = 2, Name = "Fixture Board", Layout = "BOARD_LAYOUT", Filter = null, @@ -90,6 +92,7 @@ private static IReadOnlyList CreateViews() => new ViewSnapshot { Number = 3, + TabPosition = 0, Name = "Fixture Roadmap", Layout = "ROADMAP_LAYOUT", Filter = null, diff --git a/src/Ghpmv.Core/Browser/Sel.cs b/src/Ghpmv.Core/Browser/Sel.cs index d097782d..33748c14 100644 --- a/src/Ghpmv.Core/Browser/Sel.cs +++ b/src/Ghpmv.Core/Browser/Sel.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.RegularExpressions; using Microsoft.Playwright; @@ -38,6 +39,19 @@ public static ILocator ConfigurationMenuItem(ILocator menu, string label) public static ILocator ViewTab(IPage page, string name) => page.GetByRole(AriaRole.Tab, new() { NameRegex = new Regex($"^{Regex.Escape(name)}") }); + /// A saved View tab used as a drag source or drop target, identified by its stable URL number. + public static ILocator DraggableViewTab(IPage page, int viewNumber) + { + var number = viewNumber.ToString(CultureInfo.InvariantCulture); + return page.Locator( + $"[role='tab'][href$='/views/{number}'], [role='tab'][href*='/views/{number}?']").First; + } + + /// All saved View tabs in their current DOM order (excludes the New view control). + public static ILocator SavedViewTabs(IPage page) + => page.GetByRole(AriaRole.Navigation, new() { Name = "Select view", Exact = true }) + .Locator("[role='tab'][href*='/views/']"); + /// "Save view" button (settings changes require an explicit save, D0). public static ILocator SaveViewButton(IPage page) => page.GetByRole(AriaRole.Button, new() { Name = "Save view", Exact = true }); diff --git a/src/Ghpmv.Core/Browser/ViewTabOrder.cs b/src/Ghpmv.Core/Browser/ViewTabOrder.cs new file mode 100644 index 00000000..4fb3bf27 --- /dev/null +++ b/src/Ghpmv.Core/Browser/ViewTabOrder.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using Ghpmv.Core.Snapshot; +using Microsoft.Playwright; + +namespace Ghpmv.Core.Browser; + +internal static class ViewTabOrder +{ + public static async Task> ReadAsync( + IPage page, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(page); + cancellationToken.ThrowIfCancellationRequested(); + + var tabs = Sel.SavedViewTabs(page); + var count = await tabs.CountAsync().ConfigureAwait(false); + var order = new List(count); + for (var index = 0; index < count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var href = await tabs.Nth(index).GetAttributeAsync("href").ConfigureAwait(false); + order.Add(ParseViewNumber(href)); + } + + if (order.Distinct().Count() != order.Count) + { + throw new InvalidOperationException("The saved View tab strip contained duplicate View numbers."); + } + + return order; + } + + public static IReadOnlyList Apply( + IReadOnlyList views, + IReadOnlyList orderedViewNumbers) + { + ArgumentNullException.ThrowIfNull(views); + ArgumentNullException.ThrowIfNull(orderedViewNumbers); + + var expected = views.Select(view => view.Number).ToHashSet(); + if (orderedViewNumbers.Count != views.Count + || !expected.SetEquals(orderedViewNumbers)) + { + throw new InvalidOperationException( + "The saved View tab strip did not contain exactly the Views returned by the API."); + } + + var positions = orderedViewNumbers + .Select((number, position) => (number, position)) + .ToDictionary(pair => pair.number, pair => pair.position); + return views.Select(view => view with { TabPosition = positions[view.Number] }).ToList(); + } + + internal static int ParseViewNumber(string? href) + { + if (string.IsNullOrWhiteSpace(href)) + { + throw new InvalidOperationException("A saved View tab did not expose an href."); + } + + var path = Uri.TryCreate(href, UriKind.Absolute, out var absolute) + && (string.Equals(absolute.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + || string.Equals(absolute.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + ? absolute.AbsolutePath + : href.Split('?', '#')[0]; + var marker = "/views/"; + var markerIndex = path.LastIndexOf(marker, StringComparison.Ordinal); + if (markerIndex < 0) + { + throw new InvalidOperationException($"Saved View tab href '{href}' did not contain a View number."); + } + + var numberText = path[(markerIndex + marker.Length)..].Trim('/'); + return int.TryParse(numberText, NumberStyles.None, CultureInfo.InvariantCulture, out var number) + && number > 0 + ? number + : throw new InvalidOperationException($"Saved View tab href '{href}' contained an invalid View number."); + } +} diff --git a/src/Ghpmv.Core/Browser/ViewUiExporter.cs b/src/Ghpmv.Core/Browser/ViewUiExporter.cs index ef7df310..17842bf0 100644 --- a/src/Ghpmv.Core/Browser/ViewUiExporter.cs +++ b/src/Ghpmv.Core/Browser/ViewUiExporter.cs @@ -28,6 +28,13 @@ public ViewUiExporter(BrowserSession session) /// Warnings collected while scraping (views whose UI settings could not be read). public IReadOnlyList Warnings => _warnings; + /// + /// Whether the API's POSITION-ordered View connection matched the saved-tab DOM order. + /// Null when browser tab order could not be read. A true value is a capability signal: + /// re-evaluate whether browser reads are still required before changing behavior. + /// + public bool? GraphQlPositionMatchesDomOrder { get; private set; } + /// Returns a copy of with populated. public async Task EnrichAsync(ProjectSnapshot snapshot, string orgLogin, int projectNumber, CancellationToken cancellationToken = default) => await EnrichAsync(snapshot, orgLogin, ProjectOwnerType.Organization, projectNumber, cancellationToken).ConfigureAwait(false); @@ -76,6 +83,20 @@ public async Task EnrichAsync( views.Add(view with { Ui = ui }); } + try + { + var graphQlPositionOrder = views.Select(view => view.Number).ToList(); + var tabOrder = await ViewTabOrder.ReadAsync(page, cancellationToken).ConfigureAwait(false); + GraphQlPositionMatchesDomOrder = graphQlPositionOrder.SequenceEqual(tabOrder); + views = [.. ViewTabOrder.Apply(views, tabOrder)]; + } + catch (Exception exception) when (exception is PlaywrightException or TimeoutException or InvalidOperationException) + { + GraphQlPositionMatchesDomOrder = null; + _warnings.Add($"view tab order could not be read — {exception.Message}"); + views = [.. views.Select(view => view with { TabPosition = null })]; + } + return snapshot with { Views = views }; } diff --git a/src/Ghpmv.Core/Browser/ViewUiImporter.cs b/src/Ghpmv.Core/Browser/ViewUiImporter.cs index 088be25d..981eff6f 100644 --- a/src/Ghpmv.Core/Browser/ViewUiImporter.cs +++ b/src/Ghpmv.Core/Browser/ViewUiImporter.cs @@ -150,8 +150,301 @@ public async Task EnrichAsync( _warnings.Add($"view '{view.Name}': browser-only settings could not be applied — {exception.Message}"); } } + + await ApplyTabOrderRecoverablyAsync( + () => ReorderTabsAsync( + page, + snapshot, + viewNumbers, + cancellationToken), + _warnings).ConfigureAwait(false); } + internal static async Task ApplyTabOrderRecoverablyAsync( + Func reorderAsync, + List warnings) + { + ArgumentNullException.ThrowIfNull(reorderAsync); + ArgumentNullException.ThrowIfNull(warnings); + + try + { + await reorderAsync().ConfigureAwait(false); + } + catch (Exception exception) when (exception is PlaywrightException or TimeoutException or InvalidOperationException) + { + warnings.Add($"view tab order could not be applied — {exception.Message}"); + } + } + + private async Task ReorderTabsAsync( + IPage page, + ProjectSnapshot snapshot, + IReadOnlyDictionary viewNumbers, + CancellationToken cancellationToken) + { + if (snapshot.Views.Count < 2 || snapshot.Views.Any(view => view.TabPosition is null)) + { + return; + } + + var desired = snapshot.Views + .OrderBy(view => view.TabPosition) + .Select(view => viewNumbers.TryGetValue(view.Number, out var targetNumber) + ? targetNumber + : (int?)null) + .ToList(); + if (desired.Any(number => number is null)) + { + _warnings.Add("view tab order could not be applied because one or more source views have no target mapping"); + return; + } + + var desiredNumbers = desired.Select(number => number!.Value).ToList(); + var importedNumbers = desiredNumbers.ToHashSet(); + var currentNumbers = await ReadImportedTabOrderUntilCompleteAsync( + page, + importedNumbers, + desiredNumbers.Count, + cancellationToken).ConfigureAwait(false); + if (currentNumbers.Count != desiredNumbers.Count) + { + _warnings.Add("view tab order could not be applied because the target View list is incomplete"); + return; + } + + var moves = BuildTabMovePlan(currentNumbers, desiredNumbers); + if (moves.Count == 0) + { + return; + } + + var names = snapshot.Views.ToDictionary( + view => viewNumbers[view.Number], + view => view.Name); + OnProgress?.Invoke(string.Create( + CultureInfo.InvariantCulture, + $"Reordering View tabs with {moves.Count} drag operation(s)...")); + _warnings.AddRange(await ApplyTabMovesAsync( + moves, + desiredNumbers, + names, + async (move, token) => + { + var source = Sel.DraggableViewTab(page, move.ViewNumber); + var anchor = Sel.DraggableViewTab(page, move.AnchorViewNumber); + await source.ScrollIntoViewIfNeededAsync().ConfigureAwait(false); + await anchor.ScrollIntoViewIfNeededAsync().ConfigureAwait(false); + var anchorBox = await anchor.BoundingBoxAsync().ConfigureAwait(false) + ?? throw new InvalidOperationException($"view tab '{names[move.AnchorViewNumber]}' has no visible bounding box"); + await source.DragToAsync(anchor, new() + { + TargetPosition = new() + { + X = move.PlaceBefore ? 2 : Math.Max(2, anchorBox.Width - 2), + Y = anchorBox.Height / 2, + }, + }).ConfigureAwait(false); + await PauseAsync(token).ConfigureAwait(false); + }, + token => ReadImportedTabOrderAsync( + page, + importedNumbers, + desiredNumbers, + token), + cancellationToken).ConfigureAwait(false)); + } + + internal static async Task> ApplyTabMovesAsync( + List moves, + List desiredNumbers, + Dictionary names, + Func dragAsync, + Func>> readOrderAsync, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(moves); + ArgumentNullException.ThrowIfNull(desiredNumbers); + ArgumentNullException.ThrowIfNull(names); + ArgumentNullException.ThrowIfNull(dragAsync); + ArgumentNullException.ThrowIfNull(readOrderAsync); + + var warnings = new List(); + foreach (var move in moves) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await dragAsync(move, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is PlaywrightException or TimeoutException or InvalidOperationException) + { + warnings.Add($"view tab '{names[move.ViewNumber]}' could not be reordered — {exception.Message}"); + } + } + + var actualNumbers = await readOrderAsync(cancellationToken).ConfigureAwait(false); + if (!desiredNumbers.SequenceEqual(actualNumbers)) + { + warnings.Add( + $"view tab order could not be fully applied (expected [{FormatOrder(desiredNumbers, names)}], actual [{FormatOrder(actualNumbers, names)}])"); + } + + return warnings; + } + + private static Task> ReadImportedTabOrderUntilCompleteAsync( + IPage page, + HashSet importedNumbers, + int expectedCount, + CancellationToken cancellationToken) + => PollImportedTabOrderAsync( + page, + importedNumbers, + order => order.Count == expectedCount, + cancellationToken); + + private static async Task> ReadImportedTabOrderAsync( + IPage page, + HashSet importedNumbers, + IReadOnlyList desiredNumbers, + CancellationToken cancellationToken) + => await PollImportedTabOrderAsync( + page, + importedNumbers, + order => order.SequenceEqual(desiredNumbers), + cancellationToken).ConfigureAwait(false); + + private static Task> PollImportedTabOrderAsync( + IPage page, + HashSet importedNumbers, + Func, bool> completed, + CancellationToken cancellationToken) + => PollTabOrderAsync( + async token => (await ViewTabOrder.ReadAsync(page, token).ConfigureAwait(false)) + .Where(importedNumbers.Contains) + .ToList(), + completed, + token => Task.Delay(TimeSpan.FromMilliseconds(500), token), + cancellationToken); + + internal static async Task> PollTabOrderAsync( + Func>> readAsync, + Func, bool> completed, + Func delayAsync, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(readAsync); + ArgumentNullException.ThrowIfNull(completed); + ArgumentNullException.ThrowIfNull(delayAsync); + + IReadOnlyList result = []; + for (var attempt = 0; attempt < 4; attempt++) + { + if (attempt > 0) + { + await delayAsync(cancellationToken).ConfigureAwait(false); + } + + result = await readAsync(cancellationToken).ConfigureAwait(false); + if (completed(result)) + { + break; + } + } + + return result; + } + + internal static List BuildTabMovePlan( + IReadOnlyList current, + IReadOnlyList desired) + { + ArgumentNullException.ThrowIfNull(current); + ArgumentNullException.ThrowIfNull(desired); + if (current.Count != desired.Count + || current.Distinct().Count() != current.Count + || desired.Distinct().Count() != desired.Count + || !current.ToHashSet().SetEquals(desired)) + { + throw new ArgumentException("Current and desired tab orders must contain the same unique View numbers."); + } + + if (current.SequenceEqual(desired)) + { + return []; + } + + var desiredIndexes = desired + .Select((number, index) => (number, index)) + .ToDictionary(pair => pair.number, pair => pair.index); + var sequence = current.Select(number => desiredIndexes[number]).ToArray(); + var lengths = Enumerable.Repeat(1, sequence.Length).ToArray(); + var previous = Enumerable.Repeat(-1, sequence.Length).ToArray(); + var longestEnd = 0; + for (var index = 0; index < sequence.Length; index++) + { + for (var candidate = 0; candidate < index; candidate++) + { + if (sequence[candidate] < sequence[index] + && lengths[candidate] + 1 > lengths[index]) + { + lengths[index] = lengths[candidate] + 1; + previous[index] = candidate; + } + } + + if (lengths[index] > lengths[longestEnd]) + { + longestEnd = index; + } + } + + var fixedTabs = new HashSet(); + for (var index = longestEnd; index >= 0; index = previous[index]) + { + fixedTabs.Add(current[index]); + if (previous[index] < 0) + { + break; + } + } + + var simulated = current.ToList(); + var moves = new List(desired.Count - fixedTabs.Count); + for (var index = desired.Count - 1; index >= 0; index--) + { + var number = desired[index]; + if (fixedTabs.Contains(number)) + { + continue; + } + + simulated.Remove(number); + if (index + 1 < desired.Count) + { + var anchor = desired[index + 1]; + moves.Add(new TabMove(number, anchor, PlaceBefore: true)); + simulated.Insert(simulated.IndexOf(anchor), number); + } + else + { + var anchor = simulated[^1]; + moves.Add(new TabMove(number, anchor, PlaceBefore: false)); + simulated.Add(number); + } + } + + return moves; + } + + private static string FormatOrder(IEnumerable order, Dictionary names) + => string.Join(", ", order.Select(number => names.TryGetValue(number, out var name) + ? name + : number.ToString(CultureInfo.InvariantCulture))); + + internal sealed record TabMove(int ViewNumber, int AnchorViewNumber, bool PlaceBefore); + // ----- settings ----- private async Task ApplyBrowserOnlySettingsAsync( @@ -620,4 +913,5 @@ private static bool RoadmapFieldExists(HashSet fieldNames, string value) return false; } + } diff --git a/src/Ghpmv.Core/Export/ProjectExporter.cs b/src/Ghpmv.Core/Export/ProjectExporter.cs index f6d47131..bb7d2f5d 100644 --- a/src/Ghpmv.Core/Export/ProjectExporter.cs +++ b/src/Ghpmv.Core/Export/ProjectExporter.cs @@ -56,7 +56,11 @@ public async Task ExportAsync(string ownerLogin, int projectNum } var projectInfo = ParseProjectInfo(project); - var views = ParseViews(project.GetProperty("views")); + var views = await FetchViewsAsync( + project.GetProperty("views"), + ownerLogin, + projectNumber, + cancellationToken).ConfigureAwait(false); var workflows = ParseWorkflows(project.GetProperty("workflows")); var linkedRepositories = ParseLinkedRepositories(project.GetProperty("repositories")); OnProgress?.Invoke($"Fetched {views.Count} views and {workflows.Count} workflows. Fetching items and status updates..."); @@ -236,6 +240,27 @@ private async Task> FetchFieldNodesAsync( return nodes; } + private async Task> FetchViewsAsync( + JsonElement initialConnection, + string ownerLogin, + int projectNumber, + CancellationToken cancellationToken) + { + var views = ParseViews(initialConnection); + var connection = initialConnection; + while (TryGetNextPageCursor(connection, out var after)) + { + var data = await _client.QueryAsync( + ViewsPageQuery, + new { login = ownerLogin, number = projectNumber, first = 50, after }, + cancellationToken).ConfigureAwait(false); + connection = data.GetProperty(OwnerField).GetProperty("projectV2").GetProperty("views"); + views.AddRange(ParseViews(connection)); + } + + return views; + } + private async Task> FetchLinkedTeamsAsync( string ownerLogin, int projectNumber, @@ -416,6 +441,7 @@ private static List ParseViews(JsonElement connection) views.Add(new ViewSnapshot { Number = node.GetProperty("number").GetInt32(), + TabPosition = null, Name = node.GetProperty("name").GetString() ?? string.Empty, Layout = node.GetProperty("layout").GetString() ?? string.Empty, Filter = GetOptionalString(node, "filter"), @@ -429,6 +455,20 @@ private static List ParseViews(JsonElement connection) return views; } + private static bool TryGetNextPageCursor(JsonElement connection, out string after) + { + after = string.Empty; + if (!connection.TryGetProperty("pageInfo", out var pageInfo) + || !pageInfo.GetProperty("hasNextPage").GetBoolean()) + { + return false; + } + + after = pageInfo.GetProperty("endCursor").GetString() + ?? throw new GitHubGraphQLException("The View connection reported another page without an end cursor."); + return true; + } + private static List ParseVisibleFields(JsonElement view) { if (view.TryGetProperty("configuration", out var configuration) @@ -708,6 +748,8 @@ private sealed record IssueFieldDefinition( private string FieldsQuery => FieldsQueryTemplate.Replace("__OWNER__", OwnerField, StringComparison.Ordinal); + private string ViewsPageQuery => ViewsPageQueryTemplate.Replace("__OWNER__", OwnerField, StringComparison.Ordinal); + private string StatusUpdatesQuery => StatusUpdatesQueryTemplate.Replace("__OWNER__", OwnerField, StringComparison.Ordinal); private const string TeamsQuery = @@ -753,7 +795,7 @@ private sealed record IssueFieldDefinition( public closed template - views(first: 50) { + views(first: 50, orderBy: { field: POSITION, direction: ASC }) { nodes { number name @@ -766,6 +808,7 @@ private sealed record IssueFieldDefinition( visibleFields(first: 50) { nodes { ... on ProjectV2FieldCommon { name } } } } } + pageInfo { hasNextPage endCursor } } workflows(first: 50) { nodes { number name enabled } @@ -778,6 +821,31 @@ private sealed record IssueFieldDefinition( } """; + private const string ViewsPageQueryTemplate = + """ + query($login: String!, $number: Int!, $first: Int!, $after: String) { + __OWNER__(login: $login) { + projectV2(number: $number) { + views(first: $first, after: $after, orderBy: { field: POSITION, direction: ASC }) { + nodes { + number + name + layout + filter + groupByFields(first: 10) { nodes { ... on ProjectV2FieldCommon { name } } } + verticalGroupByFields(first: 10) { nodes { ... on ProjectV2FieldCommon { name } } } + sortByFields(first: 10) { nodes { direction field { ... on ProjectV2FieldCommon { name } } } } + configuration { + visibleFields(first: 50) { nodes { ... on ProjectV2FieldCommon { name } } } + } + } + pageInfo { hasNextPage endCursor } + } + } + } + } + """; + private const string FieldsQueryTemplate = """ query($login: String!, $number: Int!, $first: Int!, $after: String) { diff --git a/src/Ghpmv.Core/Import/ProjectViewImporter.cs b/src/Ghpmv.Core/Import/ProjectViewImporter.cs index 93bc41dd..07951756 100644 --- a/src/Ghpmv.Core/Import/ProjectViewImporter.cs +++ b/src/Ghpmv.Core/Import/ProjectViewImporter.cs @@ -63,7 +63,7 @@ public async Task> ImportAsync( var usedTargetIds = new HashSet(StringComparer.Ordinal); var initiallyExistingTargetIds = targetViews.Select(view => view.Id).ToHashSet(StringComparer.Ordinal); var viewNumbers = new Dictionary(); - var orderedSourceViews = sourceViews.OrderBy(view => view.Number).ToArray(); + var orderedSourceViews = OrderSourceViews(sourceViews).ToArray(); for (var index = 0; index < orderedSourceViews.Length; index++) { @@ -96,9 +96,29 @@ public async Task> ImportAsync( } } + if (!BrowserEnrichmentPlanned) + { + WarnAboutUnappliedTabOrder(orderedSourceViews); + } + return viewNumbers; } + private static IOrderedEnumerable OrderSourceViews(IReadOnlyList sourceViews) + => sourceViews + .OrderBy(view => view.TabPosition ?? int.MaxValue) + .ThenBy(view => view.Number); + + private void WarnAboutUnappliedTabOrder(ViewSnapshot[] orderedSourceViews) + { + if (orderedSourceViews.Length < 2 || orderedSourceViews.Any(view => view.TabPosition is null)) + { + return; + } + + Warn("view tab order requires browser automation and was not applied"); + } + private void ValidatePendingOperations(IReadOnlyList sourceViews, string projectId) { var sourceByNumber = sourceViews.ToDictionary(view => view.Number); @@ -436,7 +456,7 @@ private sealed record TargetView(string Id, int Number, string Name, string Layo query($projectId: ID!, $first: Int!, $after: String) { node(id: $projectId) { ... on ProjectV2 { - views(first: $first, after: $after) { + views(first: $first, after: $after, orderBy: { field: POSITION, direction: ASC }) { nodes { id number name layout } pageInfo { hasNextPage endCursor } } diff --git a/src/Ghpmv.Core/Snapshot/ProjectSnapshot.cs b/src/Ghpmv.Core/Snapshot/ProjectSnapshot.cs index 35d8df9a..0ecec00f 100644 --- a/src/Ghpmv.Core/Snapshot/ProjectSnapshot.cs +++ b/src/Ghpmv.Core/Snapshot/ProjectSnapshot.cs @@ -205,6 +205,12 @@ public sealed record ViewSnapshot { public required int Number { get; init; } + /// + /// Zero-based saved-tab position captured from the Projects UI. Null when browser + /// automation was not used or when the tab strip could not be read. + /// + public int? TabPosition { get; init; } + public required string Name { get; init; } /// GraphQL ProjectV2ViewLayout (TABLE_LAYOUT, BOARD_LAYOUT, ROADMAP_LAYOUT). diff --git a/src/Ghpmv.Core/Verify/ProjectVerifier.cs b/src/Ghpmv.Core/Verify/ProjectVerifier.cs index 9ce8d392..71ee24b9 100644 --- a/src/Ghpmv.Core/Verify/ProjectVerifier.cs +++ b/src/Ghpmv.Core/Verify/ProjectVerifier.cs @@ -657,6 +657,8 @@ private static void CompareViews( List differences, HashSet notVerified) { + CompareViewOrder(source, target, differences, notVerified); + foreach (var name in Names(source.Select(v => v.Name), target.Select(v => v.Name))) { var s = source.Where(v => string.Equals(v.Name, name, StringComparison.Ordinal)).ToList(); @@ -729,6 +731,70 @@ private static void CompareViews( } } + private static void CompareViewOrder( + IReadOnlyList source, + IReadOnlyList target, + List differences, + HashSet notVerified) + { + if (source.Count == 0 || source.Any(view => view.TabPosition is null)) + { + return; + } + + if (target.Any(view => view.TabPosition is null)) + { + notVerified.Add(ViewCategory); + Add(differences, VerifySeverity.Warning, ViewCategory, + "view tab order was captured in the source but could not be read from the target"); + return; + } + + var sourceOrder = source + .OrderBy(view => view.TabPosition) + .ToList(); + var targetOrder = target + .OrderBy(view => view.TabPosition) + .ToList(); + if (!sourceOrder.Select(view => view.Name).SequenceEqual( + targetOrder.Select(view => view.Name), + StringComparer.Ordinal)) + { + AddError(differences, ViewCategory, + $"view tab order mismatch (source [{string.Join(", ", sourceOrder.Select(view => view.Name))}], target [{string.Join(", ", targetOrder.Select(view => view.Name))}])"); + return; + } + + foreach (var name in sourceOrder + .GroupBy(view => view.Name, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key)) + { + var sourceGroup = sourceOrder.Where(view => string.Equals(view.Name, name, StringComparison.Ordinal)).ToList(); + var targetGroup = targetOrder.Where(view => string.Equals(view.Name, name, StringComparison.Ordinal)).ToList(); + var compareUi = sourceGroup.All(view => view.Ui is not null) + && targetGroup.All(view => view.Ui is not null); + bool SameSemanticView(ViewSnapshot sourceView, ViewSnapshot targetView) + => ViewApiEquals(sourceView, targetView) + && (!compareUi || ViewUiEquals(sourceView.Ui!, targetView.Ui!)); + + if (!MultisetEquals(sourceGroup, targetGroup, SameSemanticView)) + { + continue; + } + + var sourcePositions = sourceOrder.Where(view => string.Equals(view.Name, name, StringComparison.Ordinal)); + var targetPositions = targetOrder.Where(view => string.Equals(view.Name, name, StringComparison.Ordinal)); + if (!sourcePositions.Zip(targetPositions).All(pair => SameSemanticView(pair.First, pair.Second))) + { + AddError(differences, ViewCategory, + compareUi + ? $"views named '{name}': tab order mismatch between combined API and UI settings" + : $"views named '{name}': tab order mismatch between API-visible settings"); + } + } + } + private static bool ViewApiEquals(ViewSnapshot source, ViewSnapshot target) => string.Equals(source.Layout, target.Layout, StringComparison.Ordinal) && string.Equals(source.Filter, target.Filter, StringComparison.Ordinal) diff --git a/tests/Ghpmv.Browser.Tests/BrowserRoundTripTests.cs b/tests/Ghpmv.Browser.Tests/BrowserRoundTripTests.cs index 4997ec3c..eeec76b6 100644 --- a/tests/Ghpmv.Browser.Tests/BrowserRoundTripTests.cs +++ b/tests/Ghpmv.Browser.Tests/BrowserRoundTripTests.cs @@ -198,6 +198,9 @@ public async Task Views_round_trip_through_browser_automation() var source = await exporter.ExportAsync(SourceOrg, FixtureProjectNumber, cancellationToken); source = await uiExporter.EnrichAsync(source, SourceOrg, FixtureProjectNumber, cancellationToken); Assert.Empty(uiExporter.Warnings); + Assert.False( + uiExporter.GraphQlPositionMatchesDomOrder, + "GraphQL POSITION now matches the saved-tab DOM order. Re-evaluate replacing the browser read path with the public API."); Assert.All(source.Views, v => Assert.NotNull(v.Ui)); Assert.Contains(source.Fields, field => field.Name == "Labels" && field.DataType == "LABELS"); Assert.Contains(source.Fields, field => field.Name == "Fixture Teams" && field.IssueField is not null); @@ -216,9 +219,44 @@ public async Task Views_round_trip_through_browser_automation() var sourceRoadmap = Assert.Single(source.Views, v => v.Name == "Fixture Roadmap"); Assert.Equal("Quarter", sourceRoadmap.Ui!.Roadmap?.Zoom); Assert.Contains("Fixture Date", sourceRoadmap.Ui.Roadmap?.Markers ?? []); + var sourceTabOrder = source.Views.OrderBy(view => view.TabPosition).Select(view => view.Name).ToList(); + Assert.False(sourceTabOrder.SequenceEqual( + source.Views.OrderBy(view => view.Number).Select(view => view.Name), + StringComparer.Ordinal)); var title = "ghpmv-browser-test-" + Guid.NewGuid().ToString("N"); - var snapshot = source with { Project = source.Project with { Title = title } }; + var shiftedSourceViews = source.Views.Select(view => view with + { + TabPosition = view.TabPosition + 1, + }).ToList(); + var overflowViews = Enumerable.Range(1, 6).Select(index => new ViewSnapshot + { + Number = 100 + index, + TabPosition = index == 1 ? 0 : source.Views.Count + index - 1, + Name = $"Overflow {index}", + Layout = "TABLE_LAYOUT", + GroupByFields = [], + SortByFields = [], + VerticalGroupByFields = [], + VisibleFields = [], + Ui = new ViewUiSnapshot(), + }).ToList(); + var snapshot = source with + { + Project = source.Project with { Title = title }, + Views = [.. shiftedSourceViews, .. overflowViews], + }; + var apiPositions = snapshot.Views + .OrderBy(view => view.Number) + .Select((view, position) => (view.Number, position)) + .ToDictionary(pair => pair.Number, pair => pair.position); + var apiImportSnapshot = snapshot with + { + Views = snapshot.Views.Select(view => view with + { + TabPosition = apiPositions[view.Number], + }).ToList(), + }; var userMapping = E2eTestEnvironment.Current.Users.ToMappingDictionary(); var importer = new ProjectImporter(targetClient) @@ -230,9 +268,24 @@ public async Task Views_round_trip_through_browser_automation() RepositoryMapping = RepositoryMapping, UserMapping = userMapping, }; - var result = await importer.ImportAsync(snapshot, TargetOrg, cancellationToken); + var result = await importer.ImportAsync(apiImportSnapshot, TargetOrg, cancellationToken); try { + var initialReport = await new ProjectVerifier(targetClient) + { + OrganizationMapping = OrganizationMapping, + RepositoryMapping = RepositoryMapping, + UserMapping = userMapping, + }.VerifyAsync(snapshot, TargetOrg, result.ProjectNumber, cancellationToken); + Assert.Contains(initialReport.Differences, difference => + difference.Severity == VerifySeverity.Warning + && difference.Category == "View" + && difference.Message.Contains("tab order was captured in the source", StringComparison.Ordinal)); + Assert.Contains(initialReport.Categories, category => + category.Category == "View" && category.Status == VerifyStatus.NotVerified); + + var targetPage = await targetSession.GetPageAsync(cancellationToken); + await targetPage.SetViewportSizeAsync(480, 1000); var viewImporter = new ViewUiImporter(targetSession); await viewImporter.EnrichAsync( snapshot, @@ -243,6 +296,19 @@ await viewImporter.EnrichAsync( cancellationToken); Assert.Empty(viewImporter.Warnings); + var apiOnlyReport = await new ProjectVerifier(targetClient) + { + OrganizationMapping = OrganizationMapping, + RepositoryMapping = RepositoryMapping, + UserMapping = userMapping, + }.VerifyAsync(snapshot, TargetOrg, result.ProjectNumber, cancellationToken); + Assert.Contains(apiOnlyReport.Differences, difference => + difference.Severity == VerifySeverity.Warning + && difference.Category == "View" + && difference.Message.Contains("tab order was captured in the source", StringComparison.Ordinal)); + Assert.Contains(apiOnlyReport.Categories, category => + category.Category == "View" && category.Status == VerifyStatus.NotVerified); + // Verify re-exports the target through GraphQL and its browser post-export hook. ProjectSnapshot? reExported = null; var reExportUi = new ViewUiExporter(targetSession); @@ -264,8 +330,10 @@ await viewImporter.EnrichAsync( Assert.DoesNotContain(report.Differences, difference => difference.Category == "View"); Assert.Equal(snapshot.Views.Count, target.Views.Count); - // Tab order re-creation is out of scope for v1 (PLAN §8.1) and target view - // numbers are re-assigned, so views are matched by name instead of position. + Assert.True(snapshot.Views.Count > 3); + Assert.Equal( + snapshot.Views.OrderBy(view => view.TabPosition).Select(view => view.Name), + target.Views.OrderBy(view => view.TabPosition).Select(view => view.Name)); foreach (var expected in snapshot.Views) { var actual = Assert.Single(target.Views, v => string.Equals(v.Name, expected.Name, StringComparison.Ordinal)); diff --git a/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs index 82716828..ca7aca5b 100644 --- a/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs +++ b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs @@ -1,6 +1,7 @@ using Ghpmv.Core.Browser; using Ghpmv.Core.Snapshot; using Ghpmv.Core.Verify; +using Microsoft.Playwright; namespace Ghpmv.Browser.Tests; @@ -136,6 +137,9 @@ public void FixtureUiSnapshotFactory_creates_importable_standard_views_and_workf var snapshot = FixtureUiSnapshotFactory.Create("fixture-repo"); Assert.Equal(["View 1", "Fixture Board", "Fixture Roadmap"], snapshot.Views.Select(v => v.Name)); + Assert.Equal( + ["Fixture Roadmap", "View 1", "Fixture Board"], + snapshot.Views.OrderBy(view => view.TabPosition).Select(view => view.Name)); Assert.Contains(snapshot.Fields, field => field.Name == "Fixture Teams" && field.DataType == "MULTI_SELECT" @@ -146,6 +150,173 @@ public void FixtureUiSnapshotFactory_creates_importable_standard_views_and_workf Assert.Empty(WorkflowUiImporter.CollectPreflightWarnings(snapshot, WorkflowUiImporter.DefaultMaxAutoAddWorkflows)); } + [Fact] + public void Tab_move_plan_is_empty_when_order_already_matches() + => Assert.Empty(ViewUiImporter.BuildTabMovePlan([1, 2, 3], [1, 2, 3])); + + [Fact] + public void Tab_move_plan_uses_one_drag_for_a_rotation() + { + var moves = ViewUiImporter.BuildTabMovePlan([4, 1, 2, 3], [1, 2, 3, 4]); + + var move = Assert.Single(moves); + Assert.Equal(new ViewUiImporter.TabMove(4, 3, PlaceBefore: false), move); + Assert.Equal([1, 2, 3, 4], ApplyMoves([4, 1, 2, 3], moves)); + } + + [Fact] + public void Tab_move_plan_uses_the_minimum_for_reverse_order() + { + var moves = ViewUiImporter.BuildTabMovePlan([8, 7, 6, 5, 4, 3, 2, 1], [1, 2, 3, 4, 5, 6, 7, 8]); + + Assert.Equal(7, moves.Count); + Assert.Equal([1, 2, 3, 4, 5, 6, 7, 8], ApplyMoves([8, 7, 6, 5, 4, 3, 2, 1], moves)); + } + + [Fact] + public void Tab_move_plan_handles_more_tabs_than_fit_in_the_viewport() + { + int[] current = [12, 1, 3, 2, 5, 4, 7, 6, 9, 8, 11, 10]; + int[] desired = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + + var moves = ViewUiImporter.BuildTabMovePlan(current, desired); + + Assert.Equal(desired, ApplyMoves(current, moves)); + Assert.Equal(6, moves.Count); + } + + [Fact] + public void Tab_move_plan_keeps_nonfixed_anchors_in_final_relative_order() + { + int[] current = [4, 3, 1, 2]; + int[] desired = [1, 2, 3, 4]; + + var moves = ViewUiImporter.BuildTabMovePlan(current, desired); + + Assert.Equal(2, moves.Count); + Assert.Equal(desired, ApplyMoves(current, moves)); + } + + [Fact] + public void Tab_move_plan_reaches_the_target_for_every_five_tab_permutation() + { + int[] desired = [1, 2, 3, 4, 5]; + foreach (var current in Permutations(desired)) + { + var moves = ViewUiImporter.BuildTabMovePlan(current, desired); + + Assert.Equal(desired, ApplyMoves(current, moves)); + Assert.Equal(desired.Length - LongestIncreasingSubsequenceLength(current), moves.Count); + } + } + + [Fact] + public async Task Tab_move_execution_reports_drag_and_final_readback_failures() + { + var moves = new List + { + new(2, 1, PlaceBefore: true), + }; + var desired = new List { 2, 1 }; + var names = new Dictionary { [1] = "First", [2] = "Second" }; + + var warnings = await ViewUiImporter.ApplyTabMovesAsync( + moves, + desired, + names, + (_, _) => throw new PlaywrightException("forced drag failure"), + _ => Task.FromResult>([1, 2]), + TestContext.Current.CancellationToken); + + Assert.Contains(warnings, warning => + warning.Contains("view tab 'Second' could not be reordered", StringComparison.Ordinal) + && warning.Contains("forced drag failure", StringComparison.Ordinal)); + Assert.Contains(warnings, warning => + warning.Contains("could not be fully applied", StringComparison.Ordinal) + && warning.Contains("expected [Second, First]", StringComparison.Ordinal) + && warning.Contains("actual [First, Second]", StringComparison.Ordinal)); + Assert.Equal(2, warnings.Count); + } + + [Fact] + public async Task Tab_reorder_read_failure_is_recoverable() + { + var warnings = new List(); + + await ViewUiImporter.ApplyTabOrderRecoverablyAsync( + () => throw new PlaywrightException("forced DOM read failure"), + warnings); + + var warning = Assert.Single(warnings); + Assert.Contains("view tab order could not be applied", warning, StringComparison.Ordinal); + Assert.Contains("forced DOM read failure", warning, StringComparison.Ordinal); + } + + [Fact] + public async Task Tab_order_poll_retries_an_incomplete_connection_until_all_mapped_views_are_visible() + { + var reads = new Queue>( + [ + [1], + [1, 8], + ]); + var delays = 0; + + var result = await ViewUiImporter.PollTabOrderAsync( + _ => Task.FromResult(reads.Dequeue()), + order => order.Count == 2, + _ => + { + delays++; + return Task.CompletedTask; + }, + TestContext.Current.CancellationToken); + + Assert.Equal([1, 8], result); + Assert.Empty(reads); + Assert.Equal(1, delays); + } + + [Theory] + [InlineData("/orgs/octo/projects/7/views/42", 42)] + [InlineData("/orgs/octo/projects/7/views/42?pane=info", 42)] + [InlineData("https://github.com/orgs/octo/projects/7/views/42", 42)] + public void ParseViewNumber_reads_saved_tab_href(string href, int expected) + => Assert.Equal(expected, ViewTabOrder.ParseViewNumber(href)); + + [Fact] + public void ApplyViewTabOrder_overrides_graphql_enumeration_order() + { + var table = View("Table", "TABLE_LAYOUT") with { Number = 1 }; + var board = View("Board", "BOARD_LAYOUT") with { Number = 2 }; + var roadmap = View("Roadmap", "ROADMAP_LAYOUT") with { Number = 3 }; + + var result = ViewTabOrder.Apply([table, board, roadmap], [3, 1, 2]); + + Assert.Equal( + ["Roadmap", "Table", "Board"], + result.OrderBy(view => view.TabPosition).Select(view => view.Name)); + Assert.Equal(1, result.Single(view => view.Name == "Table").TabPosition); + Assert.Equal(2, result.Single(view => view.Name == "Board").TabPosition); + Assert.Equal(0, result.Single(view => view.Name == "Roadmap").TabPosition); + } + + [Fact] + public void ApplyViewTabOrder_rejects_an_incomplete_tab_strip() + { + var views = + new[] + { + View("Table", "TABLE_LAYOUT") with { Number = 1 }, + View("Board", "BOARD_LAYOUT") with { Number = 2 }, + }; + + var exception = Assert.Throws( + () => ViewTabOrder.Apply(views, [1])); + + Assert.Contains("exactly the Views returned by the API", exception.Message, StringComparison.Ordinal); + } + // ----- verifier: Ui comparison (M6) ----- [Fact] @@ -225,4 +396,54 @@ private static ViewSnapshot View( Workflows = [], Items = [], }; + + private static List ApplyMoves( + IReadOnlyList current, + IReadOnlyList moves) + { + var result = current.ToList(); + foreach (var move in moves) + { + result.Remove(move.ViewNumber); + var anchorIndex = result.IndexOf(move.AnchorViewNumber); + result.Insert(move.PlaceBefore ? anchorIndex : anchorIndex + 1, move.ViewNumber); + } + + return result; + } + + private static IEnumerable Permutations(int[] values) + { + if (values.Length == 1) + { + yield return [values[0]]; + yield break; + } + + for (var index = 0; index < values.Length; index++) + { + var remaining = values.Where((_, candidate) => candidate != index).ToArray(); + foreach (var permutation in Permutations(remaining)) + { + yield return [values[index], .. permutation]; + } + } + } + + private static int LongestIncreasingSubsequenceLength(int[] values) + { + var lengths = Enumerable.Repeat(1, values.Length).ToArray(); + for (var index = 0; index < values.Length; index++) + { + for (var candidate = 0; candidate < index; candidate++) + { + if (values[candidate] < values[index]) + { + lengths[index] = Math.Max(lengths[index], lengths[candidate] + 1); + } + } + } + + return lengths.Max(); + } } diff --git a/tests/Ghpmv.Core.Tests/E2eTestSettingsTests.cs b/tests/Ghpmv.Core.Tests/E2eTestSettingsTests.cs index 1774909a..c1b2e4e5 100644 --- a/tests/Ghpmv.Core.Tests/E2eTestSettingsTests.cs +++ b/tests/Ghpmv.Core.Tests/E2eTestSettingsTests.cs @@ -20,6 +20,8 @@ public void Load_accepts_comments_trailing_commas_gei_and_user_mappings() "schemaVersion": 1, "source": { "organization": "source-org", + "projectsEnabled": true, + "privateRepositoryCreationAllowed": false, "apiBaseUrl": "https://api.github.com/graphql", "webBaseUrl": "https://github.com", "uploadsBaseUrl": null, @@ -29,6 +31,8 @@ public void Load_accepts_comments_trailing_commas_gei_and_user_mappings() }, "target": { "organization": "target-org", + "projectsEnabled": null, + "privateRepositoryCreationAllowed": true, "apiBaseUrl": "https://api.example.ghe.com/graphql", "webBaseUrl": "https://example.ghe.com", "uploadsBaseUrl": "https://uploads.example.ghe.com", @@ -81,6 +85,10 @@ public void Load_accepts_comments_trailing_commas_gei_and_user_mappings() var result = E2eTestSettings.Load(path); Assert.Equal("example.ghe.com", new Uri(result.Target.WebBaseUrl).Host); + Assert.True(result.Source.ProjectsEnabled); + Assert.False(result.Source.PrivateRepositoryCreationAllowed); + Assert.Null(result.Target.ProjectsEnabled); + Assert.True(result.Target.PrivateRepositoryCreationAllowed); Assert.Equal("gei-target", result.Gei.TargetRepository); Assert.Equal("migrator-active", result.Gei.SourceRole); Assert.Equal("octocat_contoso", result.Users.ToMappingDictionary()["octocat"]); diff --git a/tests/Ghpmv.Core.Tests/ProjectExporterTests.cs b/tests/Ghpmv.Core.Tests/ProjectExporterTests.cs index e3fe8c72..4c8e2b90 100644 --- a/tests/Ghpmv.Core.Tests/ProjectExporterTests.cs +++ b/tests/Ghpmv.Core.Tests/ProjectExporterTests.cs @@ -63,6 +63,73 @@ public async Task Export_prefers_configured_visible_fields_from_view_configurati Assert.Contains("visibleFields", handler.RequestBodies[0], StringComparison.Ordinal); } + [Fact] + public async Task Api_export_does_not_claim_graphql_position_is_saved_tab_order() + { + using var handler = new StubHandler( + MetadataResponse( + """ + [ + {"number":9,"name":"Roadmap","layout":"ROADMAP_LAYOUT","filter":null, + "groupByFields":{"nodes":[]},"verticalGroupByFields":{"nodes":[]},"sortByFields":{"nodes":[]}, + "configuration":{"visibleFields":{"nodes":[]}}}, + {"number":2,"name":"Table","layout":"TABLE_LAYOUT","filter":null, + "groupByFields":{"nodes":[]},"verticalGroupByFields":{"nodes":[]},"sortByFields":{"nodes":[]}, + "configuration":{"visibleFields":{"nodes":[]}}} + ] + """), + EmptyItemsResponse, + EmptyStatusUpdatesResponse, + FieldsResponse("[]")); + using var client = CreateClient(handler); + + var snapshot = await new ProjectExporter(client).ExportAsync( + "source", + 1, + TestContext.Current.CancellationToken); + + Assert.Equal(["Roadmap", "Table"], snapshot.Views.Select(view => view.Name)); + Assert.All(snapshot.Views, view => Assert.Null(view.TabPosition)); + Assert.Contains( + "views(first: 50, orderBy: { field: POSITION, direction: ASC })", + handler.RequestBodies[0], + StringComparison.Ordinal); + } + + [Fact] + public async Task Export_paginates_views_without_claiming_tab_positions() + { + using var handler = new StubHandler( + MetadataResponse( + """ + [{"number":9,"name":"First","layout":"TABLE_LAYOUT","filter":null, + "groupByFields":{"nodes":[]},"verticalGroupByFields":{"nodes":[]},"sortByFields":{"nodes":[]}, + "configuration":{"visibleFields":{"nodes":[]}}}] + """, + hasNextPage: true, + endCursor: "view-cursor"), + ViewsResponse( + """ + [{"number":2,"name":"Second","layout":"BOARD_LAYOUT","filter":null, + "groupByFields":{"nodes":[]},"verticalGroupByFields":{"nodes":[]},"sortByFields":{"nodes":[]}, + "configuration":{"visibleFields":{"nodes":[]}}}] + """), + EmptyItemsResponse, + EmptyStatusUpdatesResponse, + FieldsResponse("[]")); + using var client = CreateClient(handler); + + var snapshot = await new ProjectExporter(client).ExportAsync( + "source", + 1, + TestContext.Current.CancellationToken); + + Assert.Equal(["First", "Second"], snapshot.Views.Select(view => view.Name)); + Assert.All(snapshot.Views, view => Assert.Null(view.TabPosition)); + Assert.Contains("\"after\":\"view-cursor\"", handler.RequestBodies[1], StringComparison.Ordinal); + Assert.Contains("orderBy", handler.RequestBodies[1], StringComparison.Ordinal); + } + [Fact] public async Task Export_reads_linked_issue_field_identity_and_definition_directly_from_project_fields() { @@ -640,13 +707,28 @@ private static string StatusUpdatesResponse( ",\"pageInfo\":{\"hasNextPage\":" + hasNextPage.ToString().ToLowerInvariant() + ",\"endCursor\":" + (endCursor is null ? "null" : $"\"{endCursor}\"") + "}}}}}}"; - private static string MetadataResponse(string views, bool template = false) => + private static string MetadataResponse( + string views, + bool template = false, + bool hasNextPage = false, + string? endCursor = null) => "{\"data\":{\"organization\":{\"projectV2\":{" + "\"title\":\"Roadmap\",\"shortDescription\":null,\"readme\":null,\"public\":false,\"closed\":false," + "\"template\":" + template.ToString().ToLowerInvariant() + "," + - "\"views\":{\"nodes\":" + views + "},\"workflows\":{\"nodes\":[]},\"repositories\":{\"nodes\":[]}" + + "\"views\":{\"nodes\":" + views + + ",\"pageInfo\":{\"hasNextPage\":" + hasNextPage.ToString().ToLowerInvariant() + + ",\"endCursor\":" + (endCursor is null ? "null" : $"\"{endCursor}\"") + "}}," + + "\"workflows\":{\"nodes\":[]},\"repositories\":{\"nodes\":[]}" + "}}}}"; + private static string ViewsResponse( + string views, + bool hasNextPage = false, + string? endCursor = null) => + "{\"data\":{\"organization\":{\"projectV2\":{\"views\":{\"nodes\":" + views + + ",\"pageInfo\":{\"hasNextPage\":" + hasNextPage.ToString().ToLowerInvariant() + + ",\"endCursor\":" + (endCursor is null ? "null" : $"\"{endCursor}\"") + "}}}}}}"; + private static string FieldsResponse( string fields, bool hasNextPage = false, diff --git a/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs b/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs index aef57a9f..4ae20e78 100644 --- a/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs +++ b/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs @@ -552,6 +552,121 @@ source.Views[0] with Assert.Equal(VerifyStatus.Mismatch, report.Status); } + [Fact] + public void Captured_view_tab_order_is_compared_when_both_snapshots_include_positions() + { + var baseline = BuildSnapshot(); + var table = baseline.Views[0] with { Number = 7, TabPosition = 0 }; + var board = table with { Number = 3, TabPosition = 1, Name = "Board", Layout = "BOARD_LAYOUT" }; + var source = baseline with { Views = [table, board] }; + var target = baseline with + { + Views = + [ + table with { TabPosition = 1 }, + board with { TabPosition = 0 }, + ], + }; + + var report = ProjectVerifier.Compare(source, target); + + Assert.Contains(report.Differences, difference => + difference.Severity == VerifySeverity.Error + && difference.Category == "View" + && difference.Message.Contains("tab order mismatch", StringComparison.Ordinal) + && difference.Message.Contains("Table, Board", StringComparison.Ordinal) + && difference.Message.Contains("Board, Table", StringComparison.Ordinal)); + } + + [Fact] + public void Legacy_view_positions_do_not_require_order_verification() + { + var baseline = BuildSnapshot(); + var table = baseline.Views[0] with { Number = 7, TabPosition = null }; + var board = table with { Number = 3, Name = "Board", Layout = "BOARD_LAYOUT" }; + var source = baseline with { Views = [table, board] }; + var target = baseline with { Views = [board, table] }; + + var report = ProjectVerifier.Compare(source, target); + + Assert.DoesNotContain(report.Differences, difference => + difference.Category == "View" + && difference.Message.Contains("tab order", StringComparison.Ordinal)); + } + + [Fact] + public void Positioned_duplicate_view_names_use_api_settings_to_detect_a_swap() + { + var baseline = BuildSnapshot(); + var todo = baseline.Views[0] with + { + Number = 7, + TabPosition = 0, + Name = "Duplicate", + Filter = "status:Todo", + }; + var done = todo with + { + Number = 9, + TabPosition = 1, + Filter = "status:Done", + }; + var source = baseline with { Views = [todo, done] }; + var target = baseline with + { + Views = + [ + done with { Number = 21, TabPosition = 0 }, + todo with { Number = 22, TabPosition = 1 }, + ], + }; + + var report = ProjectVerifier.Compare(source, target); + + Assert.Contains(report.Differences, difference => + difference.Severity == VerifySeverity.Error + && difference.Category == "View" + && difference.Message.Contains("views named 'Duplicate'", StringComparison.Ordinal) + && difference.Message.Contains("tab order mismatch", StringComparison.Ordinal)); + } + + [Fact] + public void Positioned_duplicate_view_names_use_ui_settings_to_detect_a_swap() + { + var baseline = BuildSnapshot(); + var status = baseline.Views[0] with + { + Number = 7, + TabPosition = 0, + Name = "Duplicate", + Ui = new ViewUiSnapshot { SliceBy = "Status" }, + }; + var assignees = status with + { + Number = 9, + TabPosition = 1, + Ui = new ViewUiSnapshot { SliceBy = "Assignees" }, + }; + var source = baseline with { Views = [status, assignees] }; + var target = baseline with + { + Views = + [ + assignees with { Number = 21, TabPosition = 0 }, + status with { Number = 22, TabPosition = 1 }, + ], + }; + + var report = ProjectVerifier.Compare(source, target); + + Assert.Contains(report.Differences, difference => + difference.Severity == VerifySeverity.Error + && difference.Category == "View" + && difference.Message.Contains("views named 'Duplicate'", StringComparison.Ordinal) + && difference.Message.Contains("tab order mismatch", StringComparison.Ordinal) + && difference.Message.Contains("combined API and UI settings", StringComparison.Ordinal)); + } + [Fact] public void View_ui_is_not_verified_when_target_ui_was_not_read() { diff --git a/tests/Ghpmv.Core.Tests/ProjectViewImporterTests.cs b/tests/Ghpmv.Core.Tests/ProjectViewImporterTests.cs index 4f60aa71..db42d99c 100644 --- a/tests/Ghpmv.Core.Tests/ProjectViewImporterTests.cs +++ b/tests/Ghpmv.Core.Tests/ProjectViewImporterTests.cs @@ -275,6 +275,87 @@ await importer.ImportAsync( } } + [Fact] + public async Task Views_are_imported_by_tab_position_instead_of_view_number() + { + var directory = Directory.CreateTempSubdirectory("ghpmv-view-position-order-").FullName; + try + { + using var handler = new ViewHandler(directory); + using var client = CreateClient(handler); + var log = new ProjectImportLog(); + var importer = new ProjectViewImporter(client, log, ct => log.SaveAsync(directory, ct)) + { + BrowserEnrichmentPlanned = true, + }; + var firstTab = View(9, "Roadmap", "ROADMAP_LAYOUT", filter: null, visibleFields: []) + with { TabPosition = 0 }; + var secondTab = View(2, "Table", "TABLE_LAYOUT", filter: null, visibleFields: []) + with { TabPosition = 1 }; + + await importer.ImportAsync( + [secondTab, firstTab], + "PVT_target", + new Dictionary(), + ProjectImportOutcome.Created, + TestContext.Current.CancellationToken); + + var updates = handler.RequestBodies + .Where(body => body.Contains("updateProjectV2View", StringComparison.Ordinal)) + .Select(body => JsonDocument.Parse(body)) + .ToList(); + try + { + Assert.Equal( + ["Roadmap", "Table"], + updates.Select(document => + document.RootElement.GetProperty("variables").GetProperty("name").GetString())); + } + finally + { + foreach (var update in updates) + { + update.Dispose(); + } + } + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task Api_only_import_warns_when_tab_order_cannot_be_repaired() + { + var directory = Directory.CreateTempSubdirectory("ghpmv-view-position-warning-").FullName; + try + { + using var handler = new ViewHandler(directory); + using var client = CreateClient(handler); + var log = new ProjectImportLog(); + var importer = new ProjectViewImporter(client, log, ct => log.SaveAsync(directory, ct)); + var firstTab = View(9, "Roadmap", "ROADMAP_LAYOUT", filter: null, visibleFields: []) + with { TabPosition = 0 }; + var secondTab = View(2, "Table", "TABLE_LAYOUT", filter: null, visibleFields: []) + with { TabPosition = 1 }; + + await importer.ImportAsync( + [secondTab, firstTab], + "PVT_target", + new Dictionary(), + ProjectImportOutcome.Created, + TestContext.Current.CancellationToken); + + Assert.Contains(importer.Warnings, warning => + warning.Contains("tab order requires browser automation", StringComparison.Ordinal)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + private static GitHubGraphQLClient CreateClient(HttpMessageHandler handler) => new("token", new Uri("https://example.test/graphql"), handler, (_, _) => Task.CompletedTask); diff --git a/tests/Ghpmv.Core.Tests/SnapshotTests.cs b/tests/Ghpmv.Core.Tests/SnapshotTests.cs index a63fdba3..3d43129c 100644 --- a/tests/Ghpmv.Core.Tests/SnapshotTests.cs +++ b/tests/Ghpmv.Core.Tests/SnapshotTests.cs @@ -71,6 +71,7 @@ public class SnapshotTests new ViewSnapshot { Number = 1, + TabPosition = 0, Name = "View 1", Layout = "TABLE_LAYOUT", Filter = "is:issue -status:Done", @@ -261,6 +262,35 @@ public void Deserialize_snapshot_without_collaborators_and_linked_repositories_y Assert.Null(restored.LinkedTeams); } + [Fact] + public void View_without_tab_position_remains_backward_compatible() + { + const string Json = + """ + { + "schemaVersion": 1, + "project": { "title": "T", "public": false, "closed": false }, + "fields": [], + "views": [{ + "number": 7, + "name": "Legacy", + "layout": "TABLE_LAYOUT", + "groupByFields": [], + "sortByFields": [], + "verticalGroupByFields": [], + "visibleFields": [] + }], + "workflows": [], + "items": [] + } + """; + + var restored = JsonSerializer.Deserialize(Json, SnapshotJsonContext.Default.ProjectSnapshot); + + Assert.Null(Assert.Single(restored!.Views).TabPosition); + Assert.Equal(1, restored.SchemaVersion); + } + [Fact] public void Serialized_json_contains_schema_version() { @@ -301,6 +331,7 @@ public async Task SnapshotFile_saves_and_loads_snapshot_json() var restored = await SnapshotFile.LoadAsync(directory, TestContext.Current.CancellationToken); Assert.Equal(original.SchemaVersion, restored.SchemaVersion); Assert.Equal(original.Project, restored.Project); + Assert.Equal(0, Assert.Single(restored.Views).TabPosition); Assert.Equal(original.Items.Count, restored.Items.Count); Assert.NotNull(restored.StatusUpdates); Assert.Equal(original.StatusUpdates!.Count, restored.StatusUpdates.Count); diff --git a/tests/Ghpmv.Integration.Tests/GraphQLClientIntegrationTests.cs b/tests/Ghpmv.Integration.Tests/GraphQLClientIntegrationTests.cs index 0d07e4e8..b49e4358 100644 --- a/tests/Ghpmv.Integration.Tests/GraphQLClientIntegrationTests.cs +++ b/tests/Ghpmv.Integration.Tests/GraphQLClientIntegrationTests.cs @@ -55,7 +55,7 @@ public async Task Fixture_project_export_is_complete_or_fails_closed() } [Fact] - public async Task QueryPaginatedAsync_enumerates_120_items_across_real_pages() + public async Task QueryPaginatedAsync_and_ProjectExporter_enumerate_all_items_across_real_pages() { using var client = IntegrationTestSettings.CreateClient(Token); var cancellationToken = TestContext.Current.CancellationToken; @@ -68,25 +68,31 @@ public async Task QueryPaginatedAsync_enumerates_120_items_across_real_pages() var title = $"ghpmv-test-{Guid.NewGuid():N}"; var createData = await client.QueryAsync( - "mutation($ownerId: ID!, $title: String!) { createProjectV2(input: {ownerId: $ownerId, title: $title}) { projectV2 { id } } }", + "mutation($ownerId: ID!, $title: String!) { createProjectV2(input: {ownerId: $ownerId, title: $title}) { projectV2 { id number } } }", new { ownerId, title }, cancellationToken); - var projectId = createData.GetProperty("createProjectV2").GetProperty("projectV2").GetProperty("id").GetString()!; + var project = createData.GetProperty("createProjectV2").GetProperty("projectV2"); + var projectId = project.GetProperty("id").GetString()!; + var projectNumber = project.GetProperty("number").GetInt32(); try { + var createdTitles = Enumerable.Range(1, 120) + .Select(i => $"Draft {i:D3}") + .ToArray(); + // Serial on purpose: parallel writes would trip the secondary rate limit. - for (var i = 1; i <= 120; i++) + foreach (var draftTitle in createdTitles) { await client.QueryAsync( "mutation($projectId: ID!, $title: String!) { addProjectV2DraftIssue(input: {projectId: $projectId, title: $title}) { projectItem { id } } }", - new { projectId, title = $"Draft {i:D3}" }, + new { projectId, title = draftTitle }, cancellationToken); } // The items connection is eventually consistent right after writes, // so poll until all 120 items are visible (up to ~75s). - List itemIds = []; + List directTitles = []; for (var attempt = 0; attempt < 16; attempt++) { if (attempt > 0) @@ -94,14 +100,18 @@ await client.QueryAsync( await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); } - itemIds = []; + directTitles = []; await foreach (var node in client.QueryPaginatedAsync( """ query($projectId: ID!, $first: Int!, $after: String) { node(id: $projectId) { ... on ProjectV2 { - items(first: $first, after: $after) { - nodes { id } + items(first: $first, after: $after, archivedStates: [ARCHIVED, NOT_ARCHIVED]) { + nodes { + content { + ... on DraftIssue { title } + } + } pageInfo { hasNextPage endCursor } } } @@ -112,17 +122,52 @@ ... on ProjectV2 { "node.items", cancellationToken: cancellationToken)) { - itemIds.Add(node.GetProperty("id").GetString()); + directTitles.Add(node.GetProperty("content").GetProperty("title").GetString()!); } - if (itemIds.Count >= 120) + if (directTitles.Count >= 120) { break; } } - Assert.Equal(120, itemIds.Count); - Assert.Equal(120, itemIds.Distinct().Count()); + Assert.Equal(120, directTitles.Count); + Assert.Equal(120, directTitles.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal( + createdTitles.Order(StringComparer.Ordinal), + directTitles.Order(StringComparer.Ordinal)); + + var exporter = new ProjectExporter(client); + IReadOnlyList exportedItems = []; + for (var attempt = 0; attempt < 16; attempt++) + { + if (attempt > 0) + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + + var snapshot = await exporter.ExportAsync(Org, projectNumber, cancellationToken); + exportedItems = snapshot.Items; + if (exportedItems.Count >= 120) + { + break; + } + } + + Assert.Equal(120, exportedItems.Count); + Assert.All(exportedItems, item => + { + Assert.Equal("DRAFT_ISSUE", item.Type); + Assert.NotNull(item.Draft); + }); + + var exportedTitles = exportedItems.Select(item => item.Draft!.Title).ToArray(); + Assert.Equal(120, exportedTitles.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal( + directTitles.Order(StringComparer.Ordinal), + exportedTitles.Order(StringComparer.Ordinal)); + Assert.Equal(directTitles, exportedTitles); + Assert.Equal(Enumerable.Range(0, 120), exportedItems.Select(item => item.Position)); } finally { diff --git a/tests/Ghpmv.Integration.Tests/ImportCapabilityPreflightIntegrationTests.cs b/tests/Ghpmv.Integration.Tests/ImportCapabilityPreflightIntegrationTests.cs new file mode 100644 index 00000000..024f41b3 --- /dev/null +++ b/tests/Ghpmv.Integration.Tests/ImportCapabilityPreflightIntegrationTests.cs @@ -0,0 +1,203 @@ +using System.Runtime.ExceptionServices; +using System.Text.Json; +using Ghpmv.Core.Export; +using Ghpmv.Core.Import; +using Ghpmv.Core.Snapshot; + +namespace Ghpmv.Integration.Tests; + +public class ImportCapabilityPreflightIntegrationTests +{ + private static string Token + { + get + { + var token = Environment.GetEnvironmentVariable("GHPMV_TEST_TOKEN"); + Assert.SkipWhen(string.IsNullOrWhiteSpace(token), "GHPMV_TEST_TOKEN is not set; skipping real-API test."); + return token!; + } + } + + [Fact] + public async Task Production_preflight_validates_organization_repository_and_members_capabilities() + { + var cancellationToken = TestContext.Current.CancellationToken; + using var rest = IntegrationTestSettings.CreateRestClient(Token); + var sourceRepository = IntegrationTestSettings.FixtureRepositoryFullName; + var plan = new ImportCapabilityPlan( + RequiresOrganizationAdministrator: true, + RequiresProjectAdministrator: false, + RequiresMembersRead: true, + RequiresVisibilityManagement: false, + Repositories: + [ + new RepositoryCapabilityRequirement( + sourceRepository, + RepositoryCapability.MetadataRead + | RepositoryCapability.IssuesWrite + | RepositoryCapability.ContentsWrite + | RepositoryCapability.SameOwner), + ]); + var repositoryMapping = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [sourceRepository] = IntegrationTestSettings.TargetFixtureRepositoryFullName, + }; + + var exception = await Record.ExceptionAsync(() => + ImportCapabilityPreflight.ValidateAsync( + plan, + IntegrationTestSettings.TargetOrg, + repositoryMapping, + rest, + cancellationToken)); + + Assert.Null(exception); + + var repository = await rest.GetAsync( + $"repos/{IntegrationTestSettings.TargetFixtureRepositoryFullName}", + cancellationToken); + Assert.NotNull(repository); + Assert.Equal( + IntegrationTestSettings.TargetFixtureRepositoryFullName, + repository.Value.GetProperty("full_name").GetString(), + ignoreCase: true); + var permissions = repository.Value.GetProperty("permissions"); + Assert.True( + permissions.GetProperty("push").GetBoolean() + || permissions.GetProperty("maintain").GetBoolean() + || permissions.GetProperty("admin").GetBoolean(), + "The credential must have the repository role required by the preflight plan."); + + var teams = await rest.GetAsync( + $"orgs/{IntegrationTestSettings.TargetOrg}/teams?per_page=1", + cancellationToken); + Assert.NotNull(teams); + Assert.Equal(JsonValueKind.Array, teams.Value.ValueKind); + } + + [Fact] + public async Task Failed_production_preflight_runs_before_first_project_write() + { + var cancellationToken = TestContext.Current.CancellationToken; + var suffix = Guid.NewGuid().ToString("N"); + var targetTitle = $"ghpmv-preflight-failure-{suffix}"; + var sourceRepository = IntegrationTestSettings.FixtureRepositoryFullName; + var targetRepository = $"{IntegrationTestSettings.TargetOrg}/ghpmv-missing-{suffix}"; + var operationLogDirectory = IntegrationTestSettings.CreateOperationLogDirectory(); + var snapshot = MinimalSnapshot(targetTitle) with + { + LinkedRepositories = [sourceRepository], + }; + var plan = ImportCapabilityAnalyzer.Analyze(snapshot); + var repositoryMapping = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [sourceRepository] = targetRepository, + }; + + using var graphQl = IntegrationTestSettings.CreateClient(Token); + using var rest = IntegrationTestSettings.CreateRestClient(Token); + var cleanupFailures = new List(); + Exception? testFailure = null; + try + { + var preflightInvocationCount = 0; + var importer = new ProjectImporter(graphQl) + { + RepositoryMapping = repositoryMapping, + OperationLogDirectory = operationLogDirectory, + BeforeWriteAsync = async callbackCancellationToken => + { + preflightInvocationCount++; + await ImportCapabilityPreflight.ValidateAsync( + plan, + IntegrationTestSettings.TargetOrg, + repositoryMapping, + rest, + callbackCancellationToken); + }, + }; + + var exception = await Assert.ThrowsAsync(() => + importer.ImportAsync( + snapshot, + IntegrationTestSettings.TargetOrg, + cancellationToken)); + + Assert.Equal(1, preflightInvocationCount); + Assert.Contains(targetRepository, exception.Message, StringComparison.Ordinal); + Assert.Contains("not found or is not visible", exception.Message, StringComparison.Ordinal); + var projects = await new ProjectExporter(graphQl).ListProjectsAsync( + IntegrationTestSettings.TargetOrg, + includeClosed: true, + cancellationToken); + Assert.DoesNotContain( + projects, + project => string.Equals(project.Title, targetTitle, StringComparison.Ordinal)); + } + catch (Exception exception) + { + testFailure = exception; + } + finally + { + try + { + await TemporaryProjectFixture.DeleteAllByTitleAsync( + graphQl, + IntegrationTestSettings.TargetOrg, + targetTitle, + CancellationToken.None); + } + catch (Exception exception) + { + cleanupFailures.Add(exception); + } + + if (Directory.Exists(operationLogDirectory)) + { + try + { + Directory.Delete(operationLogDirectory, recursive: true); + } + catch (Exception exception) + { + cleanupFailures.Add(exception); + } + } + } + + if (testFailure is not null) + { + if (cleanupFailures.Count > 0) + { + throw new AggregateException( + "The preflight before-write test failed and one or more resources could not be cleaned up.", + [testFailure, .. cleanupFailures]); + } + + ExceptionDispatchInfo.Capture(testFailure).Throw(); + } + + if (cleanupFailures.Count > 0) + { + throw new AggregateException( + "One or more preflight before-write resources could not be cleaned up.", + cleanupFailures); + } + } + + private static ProjectSnapshot MinimalSnapshot(string title) => new() + { + SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + Project = new ProjectInfoSnapshot + { + Title = title, + Public = false, + Closed = false, + }, + Fields = [], + Views = [], + Workflows = [], + Items = [], + }; +} diff --git a/tests/Ghpmv.Integration.Tests/ItemImporterTests.cs b/tests/Ghpmv.Integration.Tests/ItemImporterTests.cs index d5f979f1..3ea7ddcd 100644 --- a/tests/Ghpmv.Integration.Tests/ItemImporterTests.cs +++ b/tests/Ghpmv.Integration.Tests/ItemImporterTests.cs @@ -338,4 +338,133 @@ private static void TryDeleteDirectory(string directory) // Best-effort cleanup of the temp log directory. } } + + [Fact] + public async Task Pull_request_item_is_imported_from_identity_mapped_repository_with_number_preserved() + { + var cancellationToken = TestContext.Current.CancellationToken; + var suffix = Guid.NewGuid().ToString("N"); + var targetProjectTitle = $"ghpmv-pr-target-project-{suffix}"; + var projectLogDirectory = IntegrationTestSettings.CreateOperationLogDirectory(); + var itemLogDirectory = Path.Combine(Path.GetTempPath(), $"ghpmv-pr-item-{suffix}"); + + using var graphQl = IntegrationTestSettings.CreateClient(Token); + var repositoryMapping = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [FixtureRepo] = FixtureRepo, + }; + var cleanupFailures = new List(); + Exception? testFailure = null; + + try + { + var fixture = await IntegrationFixtureSnapshot.CreateKnownAsync(graphQl, cancellationToken); + var sourcePullRequest = Assert.Single(fixture.Items, item => item.Type == "PULL_REQUEST"); + Assert.Equal("PULL_REQUEST", sourcePullRequest.Type); + Assert.Equal(FixtureRepo, sourcePullRequest.Repository, ignoreCase: true); + Assert.NotNull(sourcePullRequest.Number); + + var snapshot = new ProjectSnapshot + { + SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + Project = new ProjectInfoSnapshot + { + Title = targetProjectTitle, + Public = false, + Closed = false, + }, + Fields = [], + Views = [], + Workflows = [], + Items = [sourcePullRequest with { Position = 0, FieldValues = [] }], + }; + var projectImporter = new ProjectImporter(graphQl) + { + RepositoryMapping = repositoryMapping, + OperationLogDirectory = projectLogDirectory, + }; + var targetProject = await projectImporter.ImportAsync(snapshot, TargetOrg, cancellationToken); + var itemResult = await new ItemImporter(graphQl) + { + RepositoryMapping = repositoryMapping, + }.ImportAsync(snapshot, targetProject, itemLogDirectory, cancellationToken); + + Assert.Equal(1, itemResult.Created); + Assert.Equal(0, itemResult.Skipped); + Assert.Empty(itemResult.Warnings); + + var exporter = new ProjectExporter(graphQl); + var targetExport = await ExportUntilAsync( + exporter, + TargetOrg, + targetProject.ProjectNumber, + exported => exported.Items.Count == 1 + && exported.Items[0].Type == "PULL_REQUEST" + && string.Equals(exported.Items[0].Repository, FixtureRepo, StringComparison.OrdinalIgnoreCase) + && exported.Items[0].Number == sourcePullRequest.Number, + cancellationToken); + var importedPullRequest = Assert.Single(targetExport.Items); + Assert.Equal("PULL_REQUEST", importedPullRequest.Type); + Assert.Equal(FixtureRepo, importedPullRequest.Repository, ignoreCase: true); + Assert.Equal(sourcePullRequest.Number, importedPullRequest.Number); + } + catch (Exception exception) + { + testFailure = exception; + } + finally + { + foreach (var cleanup in (Func[])[ + () => TemporaryProjectFixture.DeleteAllByTitleAsync( + graphQl, + TargetOrg, + targetProjectTitle, + CancellationToken.None), + ]) + { + try + { + await cleanup(); + } + catch (Exception exception) + { + cleanupFailures.Add(exception); + } + } + + foreach (var directory in (string[])[projectLogDirectory, itemLogDirectory]) + { + if (Directory.Exists(directory)) + { + try + { + Directory.Delete(directory, recursive: true); + } + catch (Exception exception) + { + cleanupFailures.Add(exception); + } + } + } + } + + if (testFailure is not null) + { + if (cleanupFailures.Count > 0) + { + throw new AggregateException( + "The pull-request relinking test failed and one or more owned resources could not be cleaned up.", + [testFailure, .. cleanupFailures]); + } + + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(testFailure).Throw(); + } + + if (cleanupFailures.Count > 0) + { + throw new AggregateException( + "One or more pull-request relinking resources could not be cleaned up.", + cleanupFailures); + } + } } diff --git a/tests/Ghpmv.Integration.Tests/ProjectViewImporterIntegrationTests.cs b/tests/Ghpmv.Integration.Tests/ProjectViewImporterIntegrationTests.cs index a460bad8..921eb20b 100644 --- a/tests/Ghpmv.Integration.Tests/ProjectViewImporterIntegrationTests.cs +++ b/tests/Ghpmv.Integration.Tests/ProjectViewImporterIntegrationTests.cs @@ -2,12 +2,15 @@ using Ghpmv.Core.GitHub; using Ghpmv.Core.Import; using Ghpmv.Core.Snapshot; +using Ghpmv.Core.Verify; namespace Ghpmv.Integration.Tests; /// /// Verifies the GraphQL-readable Project View import contract against the real API. -/// Browser-only grouping, sorting, and UI settings are intentionally out of scope. +/// API-only coverage intentionally treats saved tab order as browser-only state. +/// Browser-only grouping, sorting, UI settings, tab order, and drag writes are +/// outside this project's scope. /// public class ProjectViewImporterIntegrationTests { @@ -74,6 +77,52 @@ await TemporaryProjectFixture.DeleteAllByTitleAsync( } } + [Fact] + public async Task Api_only_import_warns_for_browser_tab_order_and_export_leaves_it_uncaptured() + { + var cancellationToken = TestContext.Current.CancellationToken; + using var client = IntegrationTestSettings.CreateClient(Token); + var title = "ghpmv-view-position-test-" + Guid.NewGuid().ToString("N"); + var initial = PositionedSnapshot(title); + var createLogDirectory = IntegrationTestSettings.CreateOperationLogDirectory(); + try + { + var createImporter = new ProjectImporter(client) + { + OperationLogDirectory = createLogDirectory, + }; + var result = await createImporter.ImportAsync(initial, TargetOrg, cancellationToken); + Assert.Contains(createImporter.Warnings, warning => + warning.Contains("tab order requires browser automation", StringComparison.Ordinal)); + + var imported = await ExportUntilViewsMatchAsync( + client, + result.ProjectNumber, + initial.Views, + cancellationToken); + Assert.All(imported.Views, view => Assert.Null(view.TabPosition)); + + var report = ProjectVerifier.Compare(initial, imported); + Assert.Contains(report.Differences, difference => + difference.Severity == VerifySeverity.Warning + && difference.Category == "View" + && difference.Message.Contains("tab order was captured in the source", StringComparison.Ordinal)); + Assert.DoesNotContain(report.Differences, difference => + difference.Severity == VerifySeverity.Error + && difference.Category == "View" + && difference.Message.Contains("tab order", StringComparison.Ordinal)); + } + finally + { + await TemporaryProjectFixture.DeleteAllByTitleAsync( + client, + TargetOrg, + title, + CancellationToken.None); + TryDeleteDirectory(createLogDirectory); + } + } + private static ProjectSnapshot Snapshot(string title) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, @@ -106,14 +155,26 @@ await TemporaryProjectFixture.DeleteAllByTitleAsync( Items = [], }; + private static ProjectSnapshot PositionedSnapshot(string title) => Snapshot(title) with + { + Views = + [ + View(7, "Position A", "TABLE_LAYOUT", filter: null, visibleFields: [], tabPosition: 1), + View(11, "Position B", "TABLE_LAYOUT", filter: null, visibleFields: [], tabPosition: 2), + View(13, "Position C", "TABLE_LAYOUT", filter: null, visibleFields: [], tabPosition: 0), + ], + }; + private static ViewSnapshot View( int number, string name, string layout, string? filter, - IReadOnlyList visibleFields) => new() + IReadOnlyList visibleFields, + int? tabPosition = null) => new() { Number = number, + TabPosition = tabPosition, Name = name, Layout = layout, Filter = filter, @@ -152,4 +213,18 @@ private static async Task ExportUntilViewsMatchAsync( return snapshot ?? throw new InvalidOperationException("The imported project could not be exported."); } + private static void TryDeleteDirectory(string directory) + { + try + { + Directory.Delete(directory, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } diff --git a/tests/Ghpmv.TestSupport/E2eTestSettings.cs b/tests/Ghpmv.TestSupport/E2eTestSettings.cs index 64ebea5a..f3632d73 100644 --- a/tests/Ghpmv.TestSupport/E2eTestSettings.cs +++ b/tests/Ghpmv.TestSupport/E2eTestSettings.cs @@ -570,6 +570,10 @@ public sealed record E2eEndpointSettings { public string Organization { get; init; } = ""; + public bool? ProjectsEnabled { get; init; } + + public bool? PrivateRepositoryCreationAllowed { get; init; } + public string ApiBaseUrl { get; init; } = "https://api.github.com/graphql"; public string WebBaseUrl { get; init; } = "https://github.com"; diff --git a/tests/e2e.settings.jsonc b/tests/e2e.settings.jsonc index 042f8bda..c4d6d076 100644 --- a/tests/e2e.settings.jsonc +++ b/tests/e2e.settings.jsonc @@ -9,6 +9,12 @@ // Project と fixture repository を所有する Organization の login です。表示名ではありません。 "organization": "gpm-source", + // OrganizationでGitHub Projectsが有効かを記録します。true / false、未確認ならnullです。 + "projectsEnabled": null, + + // Organization policyでprivate repositoryを新規作成できるかを記録します。 + "privateRepositoryCreationAllowed": null, + // ghpmv が GraphQL API に接続するときの URL です。 // GitHub.com は下記のまま、data residency は https://api.TENANT.ghe.com/graphql に変更します。 "apiBaseUrl": "https://api.github.com/graphql", @@ -35,6 +41,13 @@ // import、verify、および一時 Project 作成先となる GitHub 環境です。 "target": { "organization": "gpm-target", + + // import先Projectを作成できるよう、target OrganizationでProjectsが有効かを記録します。 + "projectsEnabled": null, + + // fixture-seedでtarget repositoryを作る場合のOrganization policyです。GEI経路ではnullでも構いません。 + "privateRepositoryCreationAllowed": null, + "apiBaseUrl": "https://api.github.com/graphql", "webBaseUrl": "https://github.com", diff --git a/tests/e2e.settings.schema.json b/tests/e2e.settings.schema.json index cd139dec..492e6ef3 100644 --- a/tests/e2e.settings.schema.json +++ b/tests/e2e.settings.schema.json @@ -141,6 +141,14 @@ "type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$" }, + "projectsEnabled": { + "type": ["boolean", "null"], + "description": "Whether GitHub Projects is enabled for the organization; null when not yet confirmed." + }, + "privateRepositoryCreationAllowed": { + "type": ["boolean", "null"], + "description": "Whether organization policy allows the validation identity to create private repositories; null when not yet confirmed." + }, "apiBaseUrl": { "type": "string", "format": "uri",