package federation import ( "encoding/json" "fmt" "log" "strings" "time" "xorm.io/xorm" "github.com/forgeo/forgebucket/internal/models" ) // RepoAPID returns the ActivityPub actor ID for a repository. // Format: {instanceURL}/repos/{owner}/{name} func RepoAPID(instanceURL, owner, name string) string { return strings.TrimRight(instanceURL, "/") + "/repos/" + owner + "/" + name } // RepoActorJSON builds the JSON-LD actor document for a ForgeFed Repository actor. func RepoActorJSON(owner, name, description, instanceURL string) map[string]any { apid := RepoAPID(instanceURL, owner, name) return map[string]any{ "@context": []any{ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", map[string]string{ "Repository": "https://www.w3.org/ns/activitystreams#Repository", }, }, "id": apid, "type": "Repository", "preferredUsername": name, "name": owner + "/" + name, "summary": description, "inbox": apid + "/inbox", "outbox": apid + "/outbox", "followers": apid + "/followers", "following": apid + "/following", } } // HandleCreatePullRequest processes an incoming Create activity whose object // is a PullRequest (per the ForgeFed vocabulary). It creates a local PR record // in the target repository for the cross-instance proposal. func HandleCreatePullRequest(db *xorm.Engine, body []byte, instanceURL string) error { var activity struct { Actor string `json:"actor"` Object struct { Type string `json:"type"` Summary string `json:"summary"` Content string `json:"content"` Source *struct { ID string `json:"id"` Name string `json:"name"` } `json:"source"` Target *struct { ID string `json:"id"` Name string `json:"name"` } `json:"target"` } `json:"object"` } if err := json.Unmarshal(body, &activity); err != nil { return fmt.Errorf("parse activity: %w", err) } if activity.Object.Type != "PullRequest" { return nil } // Extract target repository info from the object's target. targetID := activity.Object.Target.ID targetParts := strings.Split(strings.TrimRight(targetID, "/"), "/") if len(targetParts) < 2 { return fmt.Errorf("cannot parse target repo APID: %s", targetID) } // Last two segments should be owner/repo-name. repoOwner := targetParts[len(targetParts)-2] repoName := targetParts[len(targetParts)-1] // Resolve the target repository. var repo models.Repository found, err := db.Where("name = ?", repoName). Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", repoOwner). Get(&repo) if err != nil { return fmt.Errorf("database error: %w", err) } if !found { return fmt.Errorf("target repo %s/%s not found on this instance", repoOwner, repoName) } // Resolve or create a FederationActor for the repo owner (needed for key ops). var ownerUser models.User if found, _ := db.Where("username = ?", repoOwner).Get(&ownerUser); !found { return fmt.Errorf("owner user %s not found", repoOwner) } localActor, err := GetOrCreate(db, ownerUser.ID, repoOwner, instanceURL) if err != nil { return fmt.Errorf("get actor: %w", err) } // Determine the PR title and body. title := activity.Object.Summary if title == "" { title = fmt.Sprintf("Cross-instance PR from %s", activity.Actor) } bodyContent := activity.Object.Content if bodyContent == "" { bodyContent = fmt.Sprintf("Pull request proposed via ActivityPub from %s", activity.Actor) } // Create the PR. For cross-instance PRs, authorID is set to the target // repo owner (we can't create a user for the remote actor automatically). // The RemoteSource field records the source repository APID. pr := &models.PullRequest{ RepoID: repo.ID, AuthorID: ownerUser.ID, Title: title, Body: bodyContent, SourceBranch: "refs/for/main", TargetBranch: "main", Status: models.PRStatusOpen, RemoteSource: activity.Actor, } // Try to extract source branch from the source repo. if activity.Object.Source != nil { sourceID := activity.Object.Source.ID if sourceID != "" { pr.RemoteSource = sourceID } if activity.Object.Source.Name != "" { pr.SourceBranch = activity.Object.Source.Name } } if _, err := db.Insert(pr); err != nil { return fmt.Errorf("insert PR: %w", err) } // Persist the outbound Accept for the PR activity so the remote knows // we received it (we auto-accept all incoming PRs). accept := map[string]any{ "@context": "https://www.w3.org/ns/activitystreams", "id": localActor.APID + "/activities/accept-pr-" + fmt.Sprint(time.Now().UnixNano()), "type": "Accept", "actor": localActor.APID, } acceptJSON, _ := json.Marshal(accept) db.Insert(&models.FederationActivity{ //nolint:errcheck ActorAPID: localActor.APID, Type: "Accept", ObjectJSON: string(acceptJSON), Direction: "outbound", RemoteActor: activity.Actor, Published: time.Now().UTC(), }) log.Printf("forgefed: created PR %d from cross-instance actor %s", pr.ID, activity.Actor) return nil } // SendCreatePullRequest delivers a Create(PullRequest) activity to a remote // instance's inbox. The remote inbox URL is derived from the forked-from repo's // APID by appending /inbox. func SendCreatePullRequest(db *xorm.Engine, localActor *models.FederationActor, pr *models.PullRequest, remoteAPID, instanceURL string) error { // Build the Create(PullRequest) activity. activity := map[string]any{ "@context": "https://www.w3.org/ns/activitystreams", "id": localActor.APID + "/activities/create-pr-" + fmt.Sprint(time.Now().UnixNano()), "type": "Create", "actor": localActor.APID, "object": map[string]any{ "type": "PullRequest", "id": localActor.APID + "/pull-requests/" + fmt.Sprint(pr.ID), "summary": pr.Title, "content": pr.Body, "source": map[string]any{ "type": "Repository", "id": localActor.APID, }, "target": map[string]any{ "type": "Repository", "id": remoteAPID, }, }, "to": []string{remoteAPID + "/inbox", "https://www.w3.org/ns/activitystreams#Public"}, } remoteInbox := strings.TrimSuffix(remoteAPID, "/") + "/inbox" if err := DeliverActivity(localActor, activity, remoteInbox); err != nil { return fmt.Errorf("deliver PR to %s: %w", remoteInbox, err) } actJSON, _ := json.Marshal(activity) db.Insert(&models.FederationActivity{ //nolint:errcheck ActorAPID: localActor.APID, Type: "Create", ObjectJSON: string(actJSON), Direction: "outbound", RemoteActor: remoteAPID, Published: time.Now().UTC(), }) log.Printf("forgefed: sent Create(PullRequest) for PR %d to %s", pr.ID, remoteInbox) return nil } // IsCreatePullRequest checks whether the given body is a Create(PullRequest) activity. func IsCreatePullRequest(body []byte) bool { var check struct { Type string `json:"type"` Object struct { Type string `json:"type"` } `json:"object"` } if err := json.Unmarshal(body, &check); err != nil { return false } return check.Type == "Create" && check.Object.Type == "PullRequest" }