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
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@ jobs:
' <<<"$metadata"
- name: Publish npm package
run: npm publish --provenance --access public
- name: Verify npm publication is publicly discoverable
shell: bash
env:
VERSION: ${{ inputs.release_tag || github.ref_name }}
run: node scripts/verify-npm-publication.mjs "${VERSION#v}"
- name: Attach npm package to GitHub release
env:
GH_TOKEN: ${{ github.token }}
Expand Down
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "1.1.0"
".": "2.0.0"
}
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## [2.0.0] (2026-08-09)

### ⚠ BREAKING CHANGES

* **mcp:** `github.search_threads` and `github.read_source_files` now require
`repository: {owner, repo}`. Flat `owner` and `repo` request fields are no
longer accepted. See [the v2 MCP migration guide](docs/mcp-v2-migration.md).

### Features

* **mcp:** add optional repository scope to authored pull-request portfolios.

### Bug Fixes

* **mcp:** return host-neutral native resource links for durable artifacts.

## [1.1.0](https://github.com/morluto/gitcontribute/compare/v1.0.0...v1.1.0) (2026-08-08)


Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ filesystem locks, job ownership, or cancellation:
make test-race
```

The focused race lane defaults to four in-package test slots. On a constrained
machine, lower only that setting without reducing the package-level race
coverage, for example `make test-race RACE_TEST_PARALLELISM=2`.

The SQLite driver is pure Go. Keep CGO-disabled compatibility when changing
storage or build dependencies.

Expand Down
7 changes: 4 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ GOTESTSUM ?= $(shell command -v gotestsum 2>/dev/null || printf '%s/bin/gotestsu
# use more than the historical four-test cap.
TEST_PARALLELISM ?= 8
TEST_PACKAGE_PARALLELISM ?= 8
RACE_TEST_PARALLELISM ?= 4
INTEGRATION_PARALLELISM ?= 4
GOTESTSUM_FLAGS ?= --rerun-fails=2 --rerun-fails-max-failures=5

Expand Down Expand Up @@ -79,12 +80,12 @@ test-uncached:
test-race:
# Keep package-level overlap for cross-package race coverage while bounding
# in-process test concurrency for the CPU-heavy SQLite tests.
$(GO) test -short -race -p=4 -parallel=2 -timeout 600s ./internal/app ./internal/corpus ./internal/workspace
$(GO) test -short -race -p=4 -parallel=$(RACE_TEST_PARALLELISM) -timeout 600s ./internal/app ./internal/corpus ./internal/mcpserver ./internal/workspace

test-race-full:
# Keep package-level overlap for cross-package race coverage while bounding
# in-process test concurrency for the CPU-heavy SQLite tests.
$(GO) test -race -p=4 -parallel=2 -count=1 -timeout 900s ./...
$(GO) test -race -p=4 -parallel=$(RACE_TEST_PARALLELISM) -count=1 -timeout 900s ./...

test-verbose:
$(GO) test -short -v -p=$(TEST_PACKAGE_PARALLELISM) -parallel=$(TEST_PARALLELISM) -timeout 120s ./...
Expand Down Expand Up @@ -143,4 +144,4 @@ test-integration:

check: fmt-check test lint-changed

verify: fmt-check test-uncached lint-full tidy-check generate-check docs-check
verify: fmt-check vet test-uncached lint-full tidy-check generate-check docs-check
10 changes: 6 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,9 @@ known zero merge rate remains distinct from an unknown rate.

Pull-request portfolios use the ordinary repository and thread projections.
`github.sync_pull_request_portfolio` is the only public portfolio producer. Its
discriminated selection is either authored discovery or an explicit bounded
set; identity lookup, authored discovery, and scalar status refresh are
discriminated selection is either authored discovery (optionally scoped to one
repository) or an explicit bounded set; identity lookup, authored discovery,
and scalar status refresh are
internal phases rather than separately advertised operations.
REST `pr_details` and `pr_reviews` facets are combined with typed GraphQL
facets for checks, unresolved review threads, detailed merge state, merge queue,
Expand Down Expand Up @@ -398,10 +399,11 @@ live GitHub request
-> local resources/read
```

`github.search_threads` persists the returned issue or pull-request
`github.search_threads` accepts a required nested repository reference and persists the returned issue or pull-request
observations and an exact `github-thread-search.v1` result artifact. A search
page never advances repository-wide thread coverage and an empty page is not
proof that no matching live thread exists. `github.read_source_files` resolves
proof that no matching live thread exists. `github.read_source_files` accepts
the same required nested repository reference, resolves
one named ref to a commit, reads bounded repository-relative files in input
order, and stores a `source-bundle.v1` artifact. Commit SHA is the authoritative
revision; GitHub blob SHA remains a separate file identity. Source content is
Expand Down
4 changes: 2 additions & 2 deletions docs/mcp-composed-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ revision authority and are not treated as GitContribute execution results.
## Contribution collision checks

```text
github.search_threads (bounded current work)
github.sync_pull_request_portfolio(selection=authored) -> jobs.get
github.search_threads(repository={owner,repo}, bounded current work)
github.sync_pull_request_portfolio(selection=authored, repository={owner,repo}) -> jobs.get
corpus.search_pull_requests | corpus.find_pull_request_overlaps
workspace.check_merge_conflicts (only after explicit acquisition)
```
Expand Down
5 changes: 5 additions & 0 deletions docs/mcp-scalable-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ merge details, checks, files, and other children require explicit facets.
`github.read_source_files` resolves a ref once and reads up to 20 ordered
repository-relative files with per-file and total-byte limits. Its immutable
source-bundle resource records the resolved commit and blob provenance.
Both live repository acquisitions require `repository: {owner, repo}`. See the
[v2 migration guide](mcp-v2-migration.md) for request examples; flat owner and
repo arguments are rejected.

`corpus.search_code` accepts up to 20 queries over one repository or snapshot
scope. Every query uses the same offline corpus revision. It never falls back
Expand Down Expand Up @@ -97,6 +100,8 @@ Exact PR refresh uses `github.sync_pull_request_feedback`. CI uses
`github.sync_pull_request_ci`; checks and statuses are bound to the observed
head SHA. Offline authored-PR reads use `corpus.search_pull_requests`, and
overlap analysis uses `corpus.find_pull_request_overlaps`.
Use `repository: {owner, repo}` with authored portfolio synchronization or
offline portfolio reads when the portfolio must be constrained to one project.

## Jobs, partial results, and recovery

Expand Down
71 changes: 71 additions & 0 deletions docs/mcp-v2-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# MCP v2 migration: repository-bound live acquisition

Version 2 removes the flat `owner` and `repo` fields from the two live
repository-acquisition tools. Both now require one nested `repository` object.
There are no compatibility aliases or mixed forms: callers must update every
request, recovery replay, and saved tool call before connecting to a v2 server.

## `github.search_threads`

Before (v1):

```json
{
"owner": "acme",
"repo": "rocket",
"query": "cache eviction",
"kind": "issue"
}
```

After (v2):

```json
{
"repository": {"owner": "acme", "repo": "rocket"},
"query": "cache eviction",
"kind": "issue"
}
```

## `github.read_source_files`

Before (v1):

```json
{
"owner": "acme",
"repo": "rocket",
"ref": "main",
"files": [{"path": "README.md"}]
}
```

After (v2):

```json
{
"repository": {"owner": "acme", "repo": "rocket"},
"ref": "main",
"files": [{"path": "README.md"}]
}
```

The tools still return an opaque artifact URI. Follow that URI only through MCP
`resources/read`; the resource reader remains local and offline.

## Scoped authored portfolios

`github.sync_pull_request_portfolio` accepts the same optional `repository`
scope only with `selection: "authored"`. The returned job follow-up and a
truncated `corpus.search_pull_requests` recovery retain that scope. Explicit
pull-request selections are already exact and reject `repository`.

```json
{
"selection": "authored",
"repository": {"owner": "acme", "repo": "rocket"},
"state": "open",
"limit": 20
}
```
3 changes: 2 additions & 1 deletion docs/onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,8 @@ One tag version controls the Go binaries and npm package. Release automation:
4. verifies the package has no install lifecycle;
5. installs the tarball with `--ignore-scripts` and runs a smoke test;
6. enforces a 100 MB compressed-package ceiling;
7. publishes the npm package with provenance;
7. publishes the npm package with provenance and waits for its exact version,
`latest` tag, and fresh npx metadata invocation to agree;
8. publishes matching `server.json` metadata to the MCP Registry with GitHub
OIDC;
9. creates a matching GitHub release.
Expand Down
17 changes: 13 additions & 4 deletions internal/acquire/acquire.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/morluto/gitcontribute/internal/buflimit"
"github.com/morluto/gitcontribute/internal/domain"
"github.com/morluto/gitcontribute/internal/gitremote"
"github.com/morluto/gitcontribute/internal/redaction"
)

var (
Expand Down Expand Up @@ -135,7 +136,7 @@ func (execRunner) Run(ctx context.Context, name string, args ...string) (string,
return stdout.String(), buflimit.ErrOutputLimit
}
if err != nil {
return "", fmt.Errorf("exec %s: %w (stderr: %s)", name, err, strings.TrimSpace(stderr.String()))
return "", fmt.Errorf("exec %s: %w (stderr: %s)", name, err, redaction.String(strings.TrimSpace(stderr.String())))
}
return stdout.String(), nil
}
Expand Down Expand Up @@ -337,7 +338,7 @@ func (m *Manager) git(ctx context.Context, dir string, args ...string) (string,
return m.runner.Run(ctx, "git", all...)
}

func (m *Manager) cloneMirror(ctx context.Context, remote, mirrorPath string) error {
func (m *Manager) cloneMirror(ctx context.Context, remote, mirrorPath string) (resultErr error) {
parent := filepath.Dir(mirrorPath)
if err := os.MkdirAll(parent, 0700); err != nil {
return fmt.Errorf("create mirrors dir: %w", err)
Expand All @@ -347,8 +348,16 @@ func (m *Manager) cloneMirror(ctx context.Context, remote, mirrorPath string) er
tmpPath := filepath.Join(parent, tmpName)

defer func() {
if _, err := os.Stat(tmpPath); err == nil {
_ = os.RemoveAll(tmpPath)
_, err := os.Stat(tmpPath)
if errors.Is(err, os.ErrNotExist) {
return
}
if err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("inspect clone staging path: %w", err))
return
}
if err := os.RemoveAll(tmpPath); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("remove clone staging path: %w", err))
}
}()

Expand Down
52 changes: 52 additions & 0 deletions internal/acquire/acquire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,58 @@ func TestCleanupWorktreeReturnsGitRemovalFailure(t *testing.T) {
}
}

func TestCloneMirrorReportsFailedStagingCleanup(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX directory modes are not available on Windows")
}
parent := t.TempDir()
cloneErr := errors.New("clone failed")
runner := scriptedRunner(func(_ context.Context, _ string, args ...string) (string, error) {
for _, arg := range args {
if arg != "clone" {
continue
}
tmpPath := filepath.Join(parent, args[len(args)-1])
if err := os.Mkdir(tmpPath, 0755); err != nil {
return "", err
}
if err := os.Chmod(parent, 0500); err != nil {
return "", err
}
return "", cloneErr
}
t.Fatalf("unexpected git invocation: %q", args)
return "", nil
})
m := &Manager{runner: runner}

err := m.cloneMirror(context.Background(), "https://example.test/owner/repo.git", filepath.Join(parent, "repo.git"))
if chmodErr := os.Chmod(parent, 0700); chmodErr != nil {
t.Fatal(chmodErr)
}
if !errors.Is(err, cloneErr) {
t.Fatalf("clone error = %v, want clone failure", err)
}
if err == nil || !strings.Contains(err.Error(), "remove clone staging path") {
t.Fatalf("clone error omitted staging cleanup failure: %v", err)
}
entries, readErr := os.ReadDir(parent)
if readErr != nil || len(entries) != 1 || !strings.HasPrefix(entries[0].Name(), ".clone-") {
t.Fatalf("failed clone staging directory was unexpectedly removed: entries=%v err=%v", entries, readErr)
}
}

func TestExecRunnerRedactsCredentialLikeStderr(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test uses a POSIX shell to produce controlled stderr")
}
secret := "github_pat_" + strings.Repeat("a", 22)
_, err := (execRunner{}).Run(context.Background(), "sh", "-c", "printf '%s\\n' \"token=$1\" >&2; exit 1", "sh", secret)
if err == nil || strings.Contains(err.Error(), secret) || !strings.Contains(err.Error(), "[REDACTED]") {
t.Fatalf("runner error exposed credential-like stderr: %v", err)
}
}

func TestAcquireRejectsCredentialRemoteBeforeSideEffects(t *testing.T) {
fixtureUser := strings.Join([]string{"fixture", "user"}, "-")
fixturePassword := strings.Join([]string{"fixture", "password"}, "-")
Expand Down
14 changes: 10 additions & 4 deletions internal/app/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ func (s *Service) ControlStatus(ctx context.Context) (*contracts.ControlStatusRe
if err != nil {
return nil, err
}
stats, err := c.ControlStats(ctx, s.now())
now := s.now()
stats, err := c.ControlStats(ctx, now)
if err != nil {
return nil, err
}
Expand All @@ -158,12 +159,17 @@ func (s *Service) ControlStatus(ctx context.Context) (*contracts.ControlStatusRe
if resource == "" {
resource = "unknown"
}
stale := observation.ResetAt.IsZero() || !observation.ResetAt.After(now)
rateLimits[i] = contracts.RateLimitState{
Resource: resource, Limit: observation.Limit, Remaining: observation.Remaining,
Used: observation.Used, ResetAt: formatTime(observation.ResetAt),
StatusCode: observation.StatusCode, ObservedAt: formatTime(observation.ObservedAt),
Stale: stale, StatusCode: observation.StatusCode, ObservedAt: formatTime(observation.ObservedAt),
}
if observation.Limit > 0 && observation.Remaining == 0 && observation.ResetAt.After(s.now()) {
if observation.ResetAt.IsZero() {
warnings = append(warnings, fmt.Sprintf("GitHub %s rate limit observation has no reset time; quota is unknown until the next GitHub response", resource))
} else if stale {
warnings = append(warnings, fmt.Sprintf("GitHub %s rate limit observation expired; quota is unknown until the next GitHub response", resource))
} else if observation.Limit > 0 && observation.Remaining == 0 && observation.ResetAt.After(now) {
warnings = append(warnings, fmt.Sprintf("GitHub %s rate limit resets at %s", resource, formatTime(observation.ResetAt)))
}
}
Expand All @@ -176,7 +182,7 @@ func (s *Service) ControlStatus(ctx context.Context) (*contracts.ControlStatusRe
if stats.ActiveRuns > 0 || stats.ActiveJobs > 0 {
warnings = append(warnings, "background work is active")
}
if !stats.Freshest.IsZero() && s.now().Sub(stats.Freshest) > 7*24*time.Hour {
if !stats.Freshest.IsZero() && now.Sub(stats.Freshest) > 7*24*time.Hour {
warnings = append(warnings, "freshest GitHub observation is older than 7 days")
}
return &contracts.ControlStatusResult{
Expand Down
Loading
Loading