Go · 3965 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package fork
4
5 import (
6 "context"
7 "errors"
8 "fmt"
9 "strings"
10
11 "github.com/jackc/pgx/v5"
12 "github.com/jackc/pgx/v5/pgtype"
13
14 "github.com/tenseleyFlow/shithub/internal/auth/audit"
15 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
16 )
17
18 // CreateParams describes a fork-create request.
19 type CreateParams struct {
20 SourceRepoID int64
21 ActorUserID int64
22 TargetOwnerID int64 // user id; org id support lands with S31
23 // TargetName is optional. Empty defaults to the source repo's
24 // name; non-empty must pass the same name validator as repo
25 // create (the lookup against the existing rows surfaces a
26 // taken-name error if needed).
27 TargetName string
28 // TargetVisibility is optional. Empty defaults to source
29 // visibility; non-empty must pass `allowedTargetVisibility`.
30 // (Public source can fork to private; private source is
31 // pinned to private.)
32 TargetVisibility string
33 }
34
35 // CreateResult is the inserted fork shell. The on-disk clone hasn't
36 // run yet; init_status is `init_pending`. The caller is expected to
37 // enqueue the `repo:fork_clone` worker job whose payload is
38 // {SourceRepoID, ForkRepoID}.
39 type CreateResult struct {
40 Fork reposdb.Repo
41 Source reposdb.Repo
42 }
43
44 // Create writes the fork's `repos` row, applies the fork_count
45 // trigger, and emits the audit row. The on-disk clone is the
46 // caller's responsibility to enqueue (the web handler does this
47 // right after Create returns).
48 //
49 // Authorization (visibility on source, login, fork:create policy)
50 // is the caller's responsibility — this orchestrator trusts the
51 // handler's policy gate. Visibility floor + same-owner-name guards
52 // are enforced here because they're domain rules, not auth rules.
53 func Create(ctx context.Context, deps Deps, p CreateParams) (CreateResult, error) {
54 if p.ActorUserID == 0 {
55 return CreateResult{}, ErrNotLoggedIn
56 }
57 rq := reposdb.New()
58
59 source, err := rq.GetRepoByID(ctx, deps.Pool, p.SourceRepoID)
60 if err != nil {
61 if errors.Is(err, pgx.ErrNoRows) {
62 return CreateResult{}, ErrSourceNotFound
63 }
64 return CreateResult{}, fmt.Errorf("fork: load source: %w", err)
65 }
66 if source.DeletedAt.Valid {
67 return CreateResult{}, ErrSourceDeleted
68 }
69 if source.IsArchived {
70 return CreateResult{}, ErrSourceArchived
71 }
72
73 targetName := strings.TrimSpace(p.TargetName)
74 if targetName == "" {
75 targetName = source.Name
76 }
77 // Same-owner-name shortcut: if the target owner == source owner
78 // AND the name didn't change, this is a no-op fork onto itself.
79 // Users CAN fork their own repos but must rename.
80 if source.OwnerUserID.Valid && source.OwnerUserID.Int64 == p.TargetOwnerID && targetName == source.Name {
81 return CreateResult{}, ErrSelfForkSameName
82 }
83
84 visibility, ok := allowedTargetVisibility(string(source.Visibility), p.TargetVisibility)
85 if !ok {
86 return CreateResult{}, ErrVisibilityFloor
87 }
88
89 // Check name availability against the target owner.
90 exists, err := rq.ExistsRepoForOwnerUser(ctx, deps.Pool, reposdb.ExistsRepoForOwnerUserParams{
91 OwnerUserID: pgtype.Int8{Int64: p.TargetOwnerID, Valid: true},
92 Name: targetName,
93 })
94 if err != nil {
95 return CreateResult{}, fmt.Errorf("fork: name check: %w", err)
96 }
97 if exists {
98 return CreateResult{}, ErrTargetNameTaken
99 }
100
101 row, err := rq.CreateForkRepo(ctx, deps.Pool, reposdb.CreateForkRepoParams{
102 OwnerUserID: pgtype.Int8{Int64: p.TargetOwnerID, Valid: true},
103 Name: targetName,
104 Description: source.Description,
105 Visibility: reposdb.RepoVisibility(visibility),
106 DefaultBranch: source.DefaultBranch,
107 ForkOfRepoID: pgtype.Int8{Int64: source.ID, Valid: true},
108 })
109 if err != nil {
110 return CreateResult{}, fmt.Errorf("fork: insert row: %w", err)
111 }
112
113 if deps.Audit != nil {
114 _ = deps.Audit.Record(ctx, deps.Pool, p.ActorUserID,
115 audit.ActionRepoForked, audit.TargetRepo, row.ID,
116 map[string]any{"source_repo_id": source.ID})
117 }
118 return CreateResult{Fork: row, Source: source}, nil
119 }
120