diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index 1a90ac8..4876345 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -5,14 +5,14 @@ bundle: version: "0.8.0-dev" release_sequence: 0 channel: "development" - source_tree_digest: "sha256:9bc634940f75b183e5a902de5fc5f3c4afdd9f2e8de02ef2a459ae4527841d55" - digest: "sha256:c37ff41502eec54ad1d72293599902fe35165c60bc5629727a0ac67150fb1579" + source_tree_digest: "sha256:763f454f18c9900220b158e8c5f6a522daf2d4d6e2196bb538e4607e9e91d265" + digest: "sha256:16cac4e30c8961493a85bbf1853a9fbc6b2c86fa5e3573615c3f718279593781" projection: - input_digest: "sha256:bf64ffc42fdab777b0519f2930a4d513e2adfe324bc9a27011c7140c0511261c" - output_digest: "sha256:64b0ebb161455923ef3689ac05b56797fa0f7e2b522ce0b4fcf9cf6b1963833b" + input_digest: "sha256:265fe414a8ec00d7bbc53878483cfe54c6b8476c2b93f8dcbc4cfdef5e107021" + output_digest: "sha256:5032bcd3563503836e4a2b6acb5702c9401c199e31672ef8f143b7efe3c91a20" files: - path: ".gds/compiled-policy.json" digest: "sha256:807282f820294914e1c7e6ad1bf27c54a799d56305c58630254ab50ab286f379" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:2e9b931e590fa503a8adc669d97b528f3136c95cefef4ad13e0ad1a92a4502a2" + digest: "sha256:bb2546036103846e0e8f68818580d39317459fbe612ac7ce5de92d9d661ea2a2" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index d2532f9..c6aac34 100644 --- a/.github/workflows/gds-ci.yml +++ b/.github/workflows/gds-ci.yml @@ -1,8 +1,8 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # generator: gds # bundle: 0.8.0-dev -# source-tree-digest: sha256:9bc634940f75b183e5a902de5fc5f3c4afdd9f2e8de02ef2a459ae4527841d55 -# input-digest: sha256:bf64ffc42fdab777b0519f2930a4d513e2adfe324bc9a27011c7140c0511261c +# source-tree-digest: sha256:763f454f18c9900220b158e8c5f6a522daf2d4d6e2196bb538e4607e9e91d265 +# input-digest: sha256:265fe414a8ec00d7bbc53878483cfe54c6b8476c2b93f8dcbc4cfdef5e107021 # output-digest: sha256:8c045e745cc69b731bc695a4a9d58a48c10f1ab7dd85b7354db7bfd0e072711c # edit-source: # - .gds/repository.yaml diff --git a/core/app/github_readonly_test.go b/core/app/github_readonly_test.go index 3508646..8c1aef4 100644 --- a/core/app/github_readonly_test.go +++ b/core/app/github_readonly_test.go @@ -19,6 +19,7 @@ import ( "time" "github.com/NDDev-OpenNetwork/github-device-sync/core/domain" + "github.com/NDDev-OpenNetwork/github-device-sync/core/githubcoverage" "github.com/NDDev-OpenNetwork/github-device-sync/core/githubruntime" ) @@ -86,11 +87,51 @@ func TestGitHubInventoryAndReconciliationUseLiveReadOnlyRuntime(t *testing.T) { coverageData, ok := coverage.Data.(GitHubCoverageData) if coverage.ExitClass != domain.ExitSuccess || !ok || coverage.Mutation.Attempted || coverageData.Coverage.Counts["partial"] != 5 || - coverageData.Coverage.LocalIdentitiesCollected { + coverageData.Coverage.LocalIdentitiesCollected || + coverageData.Coverage.UserTokenUnion != githubcoverage.UserTokenUnionNotProven { t.Fatalf("coverage=%#v", coverage) } } +func TestGitHubCoverageIncludeLocalEmptyRootMarksIdentitiesMissing(t *testing.T) { + root := appTestRepositoryRoot(t) + runtimePath := appTestRuntimeConfig(t, root) + server := appTestGitHubServer(t, false) + services, err := NewServices(DefaultClock) + if err != nil { + t.Fatal(err) + } + client := server.Client() + client.Timeout = 5 * time.Second + services.GitHubRuntimeBuildOptions = githubruntime.BuildOptions{ + BaseURL: server.URL + "/", HTTPClient: client, AllowInsecureLoopback: true, + } + coverage := services.GitHubCoverage(context.Background(), root, GitHubCoverageOptions{ + GitHubReadOptions: GitHubReadOptions{RuntimeConfig: runtimePath}, + IncludeLocal: true, + LocalRoot: t.TempDir(), + }) + coverageData, ok := coverage.Data.(GitHubCoverageData) + if coverage.ExitClass != domain.ExitSuccess || !ok || coverage.Mutation.Attempted || + !coverageData.Coverage.LocalIdentitiesCollected || + coverageData.Coverage.Counts["partial"] != 5 || + coverageData.Coverage.UserTokenUnion != githubcoverage.UserTokenUnionNotProven { + t.Fatalf("coverage=%#v", coverage) + } + for _, repository := range coverageData.Coverage.Repositories { + found := false + for _, reason := range repository.Reasons { + if reason == "gds_identity_missing" { + found = true + break + } + } + if !found { + t.Fatalf("repository=%#v", repository) + } + } +} + func TestGitHubInventoryRejectsWrongInstallationOwner(t *testing.T) { root := appTestRepositoryRoot(t) runtimePath := appTestRuntimeConfig(t, root) diff --git a/core/cli/root.go b/core/cli/root.go index 6069c10..0dfe622 100644 --- a/core/cli/root.go +++ b/core/cli/root.go @@ -2491,7 +2491,7 @@ func (executor *executor) githubCommand() *cobra.Command { coverageOptions := app.GitHubCoverageOptions{} coverage := &cobra.Command{ Use: "coverage", - Short: "Classify App inventories against local GDS identities by GitHub repository ID", + Short: "Classify App-installation inventories against local GDS identities by GitHub repository ID", Args: cobra.NoArgs, RunE: func(child *cobra.Command, _ []string) error { return executor.run(child, func(ctx context.Context) domain.Envelope { @@ -2502,7 +2502,7 @@ func (executor *executor) githubCommand() *cobra.Command { addGitHubReadFlags(coverage, &coverageOptions.GitHubReadOptions) coverage.Flags().BoolVar( &coverageOptions.IncludeLocal, "include-local", false, - "union device-local GDS identities with App inventories by immutable GitHub repository ID", + "union device-local GDS identities with App inventories by immutable GitHub repository ID; not a PAT membership union", ) coverage.Flags().StringVar(&coverageOptions.LocalRoot, "root", "", "filesystem root for local identity discovery") coverage.Flags().IntVar(&coverageOptions.LocalMaxDepth, "max-depth", 8, "maximum directory depth for local identity discovery") diff --git a/core/githubcoverage/evaluate.go b/core/githubcoverage/evaluate.go index 6cdc71b..c12b873 100644 --- a/core/githubcoverage/evaluate.go +++ b/core/githubcoverage/evaluate.go @@ -2,6 +2,8 @@ // identities by immutable provider repository ID. It does not invent a second // inspector: gds github inventory remains one installation, gds reconcile // remains the App union, and this evaluator is the ID-stable coverage view. +// UserTokenUnion is always not-proven: PAT memberships outside App installs +// are a separate reader contract, not a silent extra in this report. package githubcoverage import ( @@ -13,11 +15,13 @@ import ( ) const ( - StatusMigrated = "migrated" - StatusPartial = "partial" - StatusDenied = "denied" - StatusUnknown = "unknown" - StatusNotApplicable = "not-applicable" + StatusMigrated = "migrated" + StatusPartial = "partial" + StatusDenied = "denied" + StatusUnknown = "unknown" + StatusNotApplicable = "not-applicable" + StatusObserved = "observed" + UserTokenUnionNotProven = "not-proven" ) type RepositoryCoverage struct { @@ -38,6 +42,7 @@ type InstallationCoverage struct { type Report struct { LocalIdentitiesCollected bool `json:"local_identities_collected"` + UserTokenUnion string `json:"user_token_union"` Installations []InstallationCoverage `json:"installations"` Repositories []RepositoryCoverage `json:"repositories"` Counts map[string]int `json:"counts"` @@ -51,6 +56,7 @@ func Evaluate( ) Report { report := Report{ LocalIdentitiesCollected: localCollected, + UserTokenUnion: UserTokenUnionNotProven, Counts: map[string]int{}, } installationStatus := map[string]string{} @@ -84,6 +90,14 @@ func Evaluate( local := map[int64]estate.IdentityRepository{} for _, identity := range identities { if identity.ProviderID <= 0 { + report.Repositories = append(report.Repositories, RepositoryCoverage{ + ProviderID: identity.ProviderID, + Owner: identity.Owner, + Name: identity.Name, + GDSRepositoryID: identity.ID, + Status: StatusUnknown, + Reasons: []string{"github_id_missing"}, + }) continue } local[identity.ProviderID] = identity @@ -111,7 +125,17 @@ func Evaluate( return report.Installations[left].InstallationID < report.Installations[right].InstallationID }) sort.Slice(report.Repositories, func(left, right int) bool { - return report.Repositories[left].ProviderID < report.Repositories[right].ProviderID + a, b := report.Repositories[left], report.Repositories[right] + if a.ProviderID != b.ProviderID { + return a.ProviderID < b.ProviderID + } + if a.GDSRepositoryID != b.GDSRepositoryID { + return a.GDSRepositoryID < b.GDSRepositoryID + } + if a.Owner != b.Owner { + return a.Owner < b.Owner + } + return a.Name < b.Name }) for _, repository := range report.Repositories { report.Counts[repository.Status]++ @@ -133,16 +157,9 @@ func classifyInstallation(result reconciler.Result, installation reconciler.Inst } switch installation.Status { case "observed", "observed-unpersisted": - return "observed" - case "identity-mismatch": - return StatusUnknown - case "not-proven": - return StatusUnknown + return StatusObserved default: - if installation.Status == "" { - return StatusUnknown - } - return installation.Status + return StatusUnknown } } diff --git a/core/githubcoverage/evaluate_test.go b/core/githubcoverage/evaluate_test.go index b4c08bd..84c17c7 100644 --- a/core/githubcoverage/evaluate_test.go +++ b/core/githubcoverage/evaluate_test.go @@ -142,6 +142,63 @@ func TestEvaluateLocalOnlyWhenAppObservedIsPartial(t *testing.T) { } } +func TestEvaluateRecordsUserTokenUnionAsNotProven(t *testing.T) { + t.Parallel() + report := Evaluate(coverageConfig(), reconciler.Result{ + Installations: []reconciler.InstallationResult{{ + InstallationID: "installation:github-personal", Status: "observed", + }}, + }, nil, false) + if report.UserTokenUnion != UserTokenUnionNotProven { + t.Fatalf("report=%#v", report) + } +} + +func TestEvaluateMapsUnknownInstallationStatusToUnknown(t *testing.T) { + t.Parallel() + report := Evaluate(coverageConfig(), reconciler.Result{ + Installations: []reconciler.InstallationResult{{ + InstallationID: "installation:github-personal", Status: "reconciler-only-status", + }}, + }, nil, false) + if report.Installations[0].Status != StatusUnknown { + t.Fatalf("installations=%#v", report.Installations) + } +} + +func TestEvaluateObservedInstallationUsesCoverageConstant(t *testing.T) { + t.Parallel() + report := Evaluate(coverageConfig(), reconciler.Result{ + Installations: []reconciler.InstallationResult{{ + InstallationID: "installation:github-personal", Status: "observed-unpersisted", + }}, + }, nil, false) + if report.Installations[0].Status != StatusObserved { + t.Fatalf("installations=%#v", report.Installations) + } +} + +func TestEvaluateReportsMissingGitHubIDWithoutCollapsingIdentities(t *testing.T) { + t.Parallel() + report := Evaluate(coverageConfig(), reconciler.Result{ + Installations: []reconciler.InstallationResult{{ + InstallationID: "installation:github-personal", Status: "observed", + }}, + }, []estate.IdentityRepository{ + {ID: "repo_zero_a", ProviderID: 0, Owner: "example-user", Name: "alpha"}, + {ID: "repo_zero_b", ProviderID: 0, Owner: "example-user", Name: "beta"}, + }, true) + if report.Counts[StatusUnknown] != 2 || len(report.Repositories) != 2 { + t.Fatalf("report=%#v", report) + } + if report.Repositories[0].GDSRepositoryID != "repo_zero_a" || + report.Repositories[1].GDSRepositoryID != "repo_zero_b" || + !containsReason(report.Repositories[0], "github_id_missing") || + !containsReason(report.Repositories[1], "github_id_missing") { + t.Fatalf("repositories=%#v", report.Repositories) + } +} + func coverageConfig() estate.Config { return estate.Config{ Root: estate.Root{Installations: []string{"installation:github-personal"}}, diff --git a/docs/contracts/cli-v1.md b/docs/contracts/cli-v1.md index 7db4bcb..81108d0 100644 --- a/docs/contracts/cli-v1.md +++ b/docs/contracts/cli-v1.md @@ -425,9 +425,13 @@ without a collected local identity are `partial`. An installation whose permission contract fails is `denied`, including repositories that already have a local GDS identity; a missing inventory is `unknown`. -`--include-local` discovers anchors under `--root` (default: cwd). User -memberships and PAT-visible repositories that no App installation can see -remain outside this command until a user-token reader exists. +`--include-local` discovers anchors under `--root` (default: cwd). Local +anchors whose GitHub repository ID is missing stay `unknown` with +`github_id_missing`; they are not dropped. The JSON report always sets +`user_token_union` to `not-proven`: user memberships and PAT-visible +repositories that no App installation can see remain outside this command +until a dedicated user-token reader exists. Do not read App/gh-cli inventory +as that union. The command performs no provider mutation. Missing runtime evidence returns exit 3. diff --git a/docs/contracts/github-provider-v1.md b/docs/contracts/github-provider-v1.md index 1d04197..cf540d7 100644 --- a/docs/contracts/github-provider-v1.md +++ b/docs/contracts/github-provider-v1.md @@ -98,7 +98,9 @@ identity, and returns current request IDs and rate metadata without persisting the token or response. `gds reconcile --plan` performs the same current reads for the exact estate installation set and emits no external mutation. `gds github coverage` classifies that App union against optional local GDS -identities by immutable GitHub repository ID. +identities by immutable GitHub repository ID. The report's `user_token_union` +field is `not-proven` until a dedicated user-token reader exists; gh-cli +inventory is still one reader per estate installation, not PAT-visible extras. `gds github governance` reads one exact repository and defaults to `observed-only`. `--compare-local` additionally proves that the current local