Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1209,6 +1209,56 @@ and verifies both user Git and reviewer identity credentials when a profile
declares both. `--json` emits structured output. `--all` cannot be combined
with `--profile`.

### `cr invites list`

```text
cr invites list --profile <name> [--owner <login> ...] [--repo <owner/name> ...] [--json]
```

Lists the pending repository invitations addressed to the profile's posting
identity, the identity `cr review` posts as: its `reviewer_credentials` when
the profile declares them, otherwise its Git credential. Each invitation shows
the repository, inviter, permission, creation time, and whether it has
expired. `--owner` and `--repo` narrow the list; matching ignores case.

On GitHub, an `APPROVE` counts toward a required review only when the posting
identity has write access to the repository. When the repository owner invites
that identity as a collaborator, `cr invites accept` accepts the invitation
with the token `cr` already holds. The token is never printed or exported.

Requires a GitHub personal access token for the posting identity. GitHub App
credentials and GitLab profiles fail with a configuration error before the
credential store is opened. GitLab adds existing users as project members
directly, so it has no invitation to accept.

### `cr invites accept`

```text
cr invites accept --profile <name> (--owner <login> | --repo <owner/name>)... [--dry-run] [--json]
```

Accepts the pending invitations whose repository matches the allowlist. At
least one `--owner` or `--repo` is required; without one the command fails with
a usage error and reads nothing.

Comment thread
piekstra marked this conversation as resolved.
Resolved
| Flag | Semantics |
|------|-----------|
| `--owner <login>` | Accept invitations to repositories owned by this login (user or organization). Repeatable. |
| `--repo <owner/name>` | Accept the invitation to this repository. Repeatable. |
| `--dry-run` | Report what would be accepted without accepting anything. |
| `--json` | Emit JSON. |

Every pending invitation gets one result: `accepted`, `would_accept` under
`--dry-run`, `skipped` with a reason (not on the allowlist, or expired), or
`failed` with the host's error. One failure does not stop the remaining
accepts. After printing every result, the command exits `1` if any accept
failed. JSON output carries the identity (`profile`, `credential_source`,
`host`, `login`), `dry_run`, the `allowlist`, the per-invitation `results`, and
a `summary` count per status. Each skipped or failed result carries a stable
`reason_code` for scripts and a human-readable `reason`. Skips use
`not_allowlisted` or `expired`. Failures use `auth`, `permission`, `not_found`,
`conflict`, `retryable`, `canceled`, or `error`.

### `cr agents list`

```text
Expand Down
2 changes: 2 additions & 0 deletions cmd/cr/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/open-cli-collective/codereview-cli/internal/cmd/datacmd"
"github.com/open-cli-collective/codereview-cli/internal/cmd/exitcode"
"github.com/open-cli-collective/codereview-cli/internal/cmd/initcmd"
"github.com/open-cli-collective/codereview-cli/internal/cmd/invitescmd"
"github.com/open-cli-collective/codereview-cli/internal/cmd/mecmd"
"github.com/open-cli-collective/codereview-cli/internal/cmd/pireviewtoolcmd"
"github.com/open-cli-collective/codereview-cli/internal/cmd/respondcmd"
Expand Down Expand Up @@ -56,6 +57,7 @@ func buildRootCommand(stdin io.Reader, stdout, stderr io.Writer) (*cobra.Command
credentialcmd.Register,
initcmd.Register,
mecmd.Register,
invitescmd.Register,
pireviewtoolcmd.Register,
agentscmd.Register,
reviewcmd.Register,
Expand Down
2 changes: 2 additions & 0 deletions cmd/cr/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ func TestRun(t *testing.T) {
{name: "dash-v is not version", args: []string{"-v"}, wantCode: 2, wantStderr: "unknown shorthand flag", wantEmptyStdout: true},
{name: "help flag shows usage", args: []string{"--help"}, wantCode: 0, wantStdout: "Usage:", wantStdoutSubstring: true},
{name: "me command wired", args: []string{"me", "--help"}, wantCode: 0, wantStdout: "Resolve and cache", wantStdoutSubstring: true},
{name: "invites command wired", args: []string{"invites", "--help"}, wantCode: 0, wantStdout: "List and accept the pending repository invitations", wantStdoutSubstring: true},
{name: "invites accept command wired", args: []string{"invites", "accept", "--help"}, wantCode: 0, wantStdout: "Accept the pending invitations whose repository is on the allowlist", wantStdoutSubstring: true},
{name: "agents command wired", args: []string{"agents", "--help"}, wantCode: 0, wantStdout: "Inspect trusted review agents", wantStdoutSubstring: true},
{name: "review command wired", args: []string{"review", "--help"}, wantCode: 0, wantStdout: "Run an automated pull-request review", wantStdoutSubstring: true},
{name: "sessions command wired", args: []string{"sessions", "--help"}, wantCode: 0, wantStdout: "Manage named orchestrator sessions", wantStdoutSubstring: true},
Expand Down
10 changes: 10 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ This keeps markers, retries, reconciliation, idempotency, and resume behavior in
one place. New commands such as `cr respond` should produce planned thread
actions and let the reviewplan/ledger/outbox flow perform provider writes.

Accepting a repository invitation is the one exception. It changes the posting
identity's account, not a pull request, so it has no marker, no run, and
nothing to reconcile, and accepting an invitation that is already gone fails
harmlessly. `cr invites accept` therefore writes directly through
`gitprovider.RepositoryInvitations`, a seam separate from `GitProvider` so the
review runtime wrappers never carry it. Only `internal/invitations`, which
enforces the required owner/repository allowlist and `--dry-run`, may call
`AcceptRepositoryInvitation`; `internal/architecture/invitation_writes_test.go`
enforces that.

## Authenticated Git Reads

`internal/gitexec` is the narrow transport adapter for non-interactive Git
Expand Down
8 changes: 5 additions & 3 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ codereview-cli is the Open CLI Collective code-review CLI and ships the `cr`
binary. It provides configuration and credential commands, trusted-agent
inspection, dry-run and live pull-request review orchestration, inline thread
response handling through `cr respond`, named orchestrator sessions, automatic
PR-scoped reviewer cohorts, and local data lifecycle commands.
PR-scoped reviewer cohorts, local data lifecycle commands, and `cr invites` for
accepting the posting identity's repository invitations.

The current Go code is a Cobra command tree in `internal/cmd/*` with a thin
`cmd/cr` entrypoint, shared exit-code mapping in `internal/cmd/exitcode`, and
Expand Down Expand Up @@ -198,9 +199,10 @@ release, change `PI_RUNTIME_VERSION` and run the target locally first.
state/config adapters in `internal/config`, `internal/ledger`, and
`internal/statepaths`, provider/LLM adapters in their owning packages, and
review posting/gating in `internal/outbox`, `internal/gate`, and
`internal/gateio`, and response-only
`internal/gateio`, response-only
inline discussion handling in
`internal/threadrespond`.
`internal/threadrespond`, and allowlisted repository-invitation acceptance
in `internal/invitations`.

## Interactive Init Notes

Expand Down
1 change: 1 addition & 0 deletions docs/init-config-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ The following flags are intentionally not durable init configuration.
| `cr data show` | `--json` | Presentation only. |
| `cr agents` | `--agents-dir`, `--json` | Per-run inspection inputs and output. Durable trusted sources use `agent_sources`. |
| `cr me` | `--all`, `--json` | Identity refresh/read behavior, not wizard configuration. |
| `cr invites` | `--owner`, `--repo`, `--dry-run`, `--json` | Per-run allowlist, filter, and preview controls for accepting the posting identity's repository invitations, not profile config. |
| `cr sessions` | `--json` | Presentation only. |
| `cr benchmark` | `--candidate`, `--case`, `--results-dir`, `--cr-bin`, `--json` | Benchmark harness controls, outside init. |

Expand Down
1 change: 1 addition & 0 deletions internal/architecture/command_boundaries_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func TestCommandPackageTestsKeepFeatureCommandImportsAtCommandTreeBoundaries(t *
"internal/cmd/noleak/noleak_test.go -> github.com/open-cli-collective/codereview-cli/internal/cmd/credentialcmd": "command-surface noleak harness registers the command tree",
"internal/cmd/noleak/noleak_test.go -> github.com/open-cli-collective/codereview-cli/internal/cmd/datacmd": "command-surface noleak harness registers the command tree",
"internal/cmd/noleak/noleak_test.go -> github.com/open-cli-collective/codereview-cli/internal/cmd/initcmd": "command-surface noleak harness registers the command tree",
"internal/cmd/noleak/noleak_test.go -> github.com/open-cli-collective/codereview-cli/internal/cmd/invitescmd": "command-surface noleak harness registers the command tree",
"internal/cmd/noleak/noleak_test.go -> github.com/open-cli-collective/codereview-cli/internal/cmd/mecmd": "command-surface noleak harness registers the command tree",
"internal/cmd/noleak/noleak_test.go -> github.com/open-cli-collective/codereview-cli/internal/cmd/reviewcmd": "command-surface noleak harness registers the command tree",
"internal/cmd/noleak/noleak_test.go -> github.com/open-cli-collective/codereview-cli/internal/cmd/sessionscmd": "command-surface noleak harness registers the command tree",
Expand Down
60 changes: 60 additions & 0 deletions internal/architecture/invitation_writes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package architecture_test

import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strings"
"testing"
)

// Accepting a repository invitation is the one account-level provider write.
// It stays inside internal/invitations, which enforces the allowlist and
// dry-run, so no other caller can accept invitations without them. Any
// selector naming the method counts, so a method value or method expression
// cannot slip past where a direct call would be caught. Adapters only declare
// the method, and declarations are not selectors.
func TestRepositoryInvitationAcceptsStayInInvitationsPackage(t *testing.T) {
repoRoot := repoRootFromTest(t)
allowedDirs := []string{"internal/invitations"}

fset := token.NewFileSet()
err := filepath.WalkDir(repoRoot, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
switch entry.Name() {
case ".git", "dist", "vendor":
return filepath.SkipDir
default:
return nil
}
}
if filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") {
return nil
}
rel := filepath.ToSlash(mustRel(t, repoRoot, path))
if pathInAllowedDirs(rel, allowedDirs) {
return nil
}
parsed, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return err
}
ast.Inspect(parsed, func(node ast.Node) bool {
selector, ok := node.(*ast.SelectorExpr)
if !ok || selector.Sel.Name != "AcceptRepositoryInvitation" {
return true
}
t.Fatalf("%s references AcceptRepositoryInvitation outside internal/invitations", fset.Position(selector.Pos()))
return false
})
return nil
})
if err != nil {
t.Fatalf("WalkDir(%s): %v", repoRoot, err)
}
}
102 changes: 77 additions & 25 deletions internal/cmd/cmderr/cmderr.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,56 @@ import (
"github.com/open-cli-collective/codereview-cli/internal/gitprovider"
)

// Sentinel groups shared by the mappers below and Classify, so each error
// class is listed once.
var (
configUsageErrors = []error{
config.ErrInvalid,
config.ErrUnsupportedEffort,
}
configAuthErrors = []error{
config.ErrNotConfigured,
config.ErrProfileNotFound,
config.ErrRepositoryProfileAmbiguous,
config.ErrSecretsStoreNotFound,
config.ErrUnsupported,
}
credentialUsageErrors = []error{
credentials.ErrInvalidBackendSelection,
credentials.ErrWrongService,
credstore.ErrRefEmpty,
credstore.ErrRefSegmentCount,
credstore.ErrRefInvalidChar,
credstore.ErrKeyNotAllowed,
credstore.ErrExists,
}
credentialAuthErrors = []error{
credstore.ErrFilePassphraseRequired,
credstore.ErrSecretServiceFailClosed,
credstore.ErrStoreClosed,
credstore.ErrBackendNotImplemented,
}
providerAuthErrors = []error{
gitprovider.ErrAuth,
gitprovider.ErrPermission,
gitprovider.ErrIneligibleReviewAuthority,
}
providerUpstreamErrors = []error{
gitprovider.ErrRetryable,
}
providerFailureErrors = []error{
gitprovider.ErrNotFound,
gitprovider.ErrConflict,
gitprovider.ErrStaleSHA,
}
)

// Config maps config package errors to scriptable command errors.
func Config(err error) error {
switch {
case errors.Is(err, config.ErrInvalid),
errors.Is(err, config.ErrUnsupportedEffort):
case isAny(err, configUsageErrors):
return exitcode.Usage(err)
case errors.Is(err, config.ErrNotConfigured),
errors.Is(err, config.ErrProfileNotFound),
errors.Is(err, config.ErrRepositoryProfileAmbiguous),
errors.Is(err, config.ErrSecretsStoreNotFound),
errors.Is(err, config.ErrUnsupported):
case isAny(err, configAuthErrors):
return exitcode.AuthConfig(err)
default:
return err
Expand All @@ -33,18 +72,9 @@ func Config(err error) error {
// Credential maps credstore-related errors to scriptable command errors.
func Credential(err error) error {
switch {
case errors.Is(err, credentials.ErrInvalidBackendSelection),
errors.Is(err, credentials.ErrWrongService),
errors.Is(err, credstore.ErrRefEmpty),
errors.Is(err, credstore.ErrRefSegmentCount),
errors.Is(err, credstore.ErrRefInvalidChar),
errors.Is(err, credstore.ErrKeyNotAllowed),
errors.Is(err, credstore.ErrExists):
case isAny(err, credentialUsageErrors):
return exitcode.Usage(err)
case errors.Is(err, credstore.ErrFilePassphraseRequired),
errors.Is(err, credstore.ErrSecretServiceFailClosed),
errors.Is(err, credstore.ErrStoreClosed),
errors.Is(err, credstore.ErrBackendNotImplemented):
case isAny(err, credentialAuthErrors):
return exitcode.AuthConfig(err)
default:
return err
Expand All @@ -62,15 +92,11 @@ func Provider(err error) error {

func providerKind(err error, kind error) error {
switch {
case errors.Is(kind, gitprovider.ErrAuth),
errors.Is(kind, gitprovider.ErrPermission),
errors.Is(kind, gitprovider.ErrIneligibleReviewAuthority):
case isAny(kind, providerAuthErrors):
return exitcode.AuthConfig(err)
case errors.Is(kind, gitprovider.ErrRetryable):
case isAny(kind, providerUpstreamErrors):
return exitcode.Upstream(err)
case errors.Is(kind, gitprovider.ErrNotFound),
errors.Is(kind, gitprovider.ErrConflict),
errors.Is(kind, gitprovider.ErrStaleSHA):
case isAny(kind, providerFailureErrors):
return exitcode.With(exitcode.Failure, err)
default:
// Unknown provider errors intentionally fall through. exitcode.FromError
Expand All @@ -80,6 +106,32 @@ func providerKind(err error, kind error) error {
}
}

// Classify maps config, git-provider, and credential-store errors onto the
// exit taxonomy, checked in that order, and returns any other error
// unchanged. Commands that load a profile, open its credential store, and
// call the provider use it so their exit codes stay aligned.
func Classify(err error) error {
switch {
case isAny(err, configUsageErrors), isAny(err, configAuthErrors):
return Config(err)
case isAny(err, providerAuthErrors), isAny(err, providerUpstreamErrors), isAny(err, providerFailureErrors):
return Provider(err)
case isAny(err, credentialUsageErrors), isAny(err, credentialAuthErrors):
return Credential(err)
default:
return err
}
}

func isAny(err error, sentinels []error) bool {
for _, sentinel := range sentinels {
if errors.Is(err, sentinel) {
return true
}
}
return false
}

// BackendFlagChanged reports whether the inherited --backend flag was supplied.
func BackendFlagChanged(cmd *cobra.Command) bool {
flag := cmd.Flag(credstore.BackendFlagName)
Expand Down
40 changes: 40 additions & 0 deletions internal/cmd/cmderr/cmderr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,43 @@ func TestProviderErrorMapping(t *testing.T) {
})
}
}

func TestClassifyMapsEveryDomainAndLeavesOthersAlone(t *testing.T) {
tests := []struct {
name string
err error
want int
}{
{name: "config usage", err: config.ErrInvalid, want: exitcode.UsageError},
{name: "config auth", err: config.ErrProfileNotFound, want: exitcode.AuthConfigError},
{name: "config unsupported", err: config.ErrUnsupported, want: exitcode.AuthConfigError},
{name: "provider auth", err: gitprovider.WrapError(gitprovider.ErrAuth, gitprovider.OperationWhoAmI, nil), want: exitcode.AuthConfigError},
{name: "provider upstream", err: gitprovider.WrapError(gitprovider.ErrRetryable, gitprovider.OperationWhoAmI, nil), want: exitcode.UpstreamError},
{name: "provider failure", err: gitprovider.WrapError(gitprovider.ErrNotFound, gitprovider.OperationWhoAmI, nil), want: exitcode.Failure},
{name: "provider auth beats credential cause", err: gitprovider.WrapError(gitprovider.ErrAuth, "", credstore.ErrFilePassphraseRequired), want: exitcode.AuthConfigError},
{name: "credential usage", err: credstore.ErrRefEmpty, want: exitcode.UsageError},
{name: "credential auth", err: credstore.ErrStoreClosed, want: exitcode.AuthConfigError},
{name: "credential backend selection", err: credentials.ErrInvalidBackendSelection, want: exitcode.UsageError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Classify(tt.err)
if got := exitcode.FromError(err); got != tt.want {
t.Fatalf("exit code = %d, want %d", got, tt.want)
}
if !errors.Is(err, tt.err) {
t.Fatalf("mapped error = %v, want it to wrap %v", err, tt.err)
}
})
}

other := errors.New("unclassified")
got := Classify(other)
if !errors.Is(got, other) || exitcode.FromError(got) != exitcode.Failure {
t.Fatalf("Classify(unclassified) = %v (exit %d), want it unchanged with the generic failure code", got, exitcode.FromError(got))
}
var coded interface{ ExitCode() int }
if errors.As(got, &coded) {
t.Fatalf("Classify(unclassified) = %T, want no exit-code wrapper", got)
}
}
Loading
Loading