| 1 | package tinygit |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "os/exec" |
| 10 | "path/filepath" |
| 11 | "sort" |
| 12 | "strings" |
| 13 | "time" |
| 14 | |
| 15 | event "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 16 | ) |
| 17 | |
| 18 | // ValidateEvent checks the wire shape, canonical ID and signature, not |
| 19 | // repository metadata shape or host admission. Use ParseMetadata for the pure |
| 20 | // metadata check, or Validate for the complete admission boundary. |
| 21 | func (g *GitRelay) ValidateEvent(e event.Event) error { return event.Validate(e) } |
| 22 | |
| 23 | // Validate checks the signature and metadata shape, resolves repository |
| 24 | // authority, then applies host admission. Shape failures precede host callbacks. |
| 25 | // The caller must associate the returned authority with its event transaction. |
| 26 | // Validate has no store side effects and does not require Git objects locally. |
| 27 | func (g *GitRelay) Validate(ctx context.Context, e event.Event) (Repository, error) { |
| 28 | if err := event.Validate(e); err != nil { |
| 29 | return Repository{}, err |
| 30 | } |
| 31 | if e.Kind != 30617 && e.Kind != 30618 { |
| 32 | return Repository{}, fmt.Errorf("unsupported: event kind %d is not GRASP repository metadata" , e.Kind) |
| 33 | } |
| 34 | r, err := g.parseRepository(e) |
| 35 | if err != nil { |
| 36 | return Repository{}, err |
| 37 | } |
| 38 | if e.Kind == 30617 { |
| 39 | if err := g.validateArchiveAnnouncement(r); err != nil { |
| 40 | return Repository{}, err |
| 41 | } |
| 42 | } |
| 43 | if g.authorize != nil { |
| 44 | if err := g.authorize(ctx, e, r); err != nil { |
| 45 | return Repository{}, err |
| 46 | } |
| 47 | } else if !g.IsMaintainer(ctx, r, e.PubKey) { |
| 48 | return Repository{}, errors.New("blocked: repository metadata must be signed by owner" ) |
| 49 | } |
| 50 | if e.Kind == 30618 { |
| 51 | if r.EventID == "" { |
| 52 | return Repository{}, errors.New("blocked: repository announcement is missing" ) |
| 53 | } |
| 54 | // Missing objects are a durable pending transition. The signed state is |
| 55 | // accepted, but remains invisible until receive-pack supplies every tip. |
| 56 | } |
| 57 | return r, nil |
| 58 | } |
| 59 | |
| 60 | // CommitAfterStore applies the Git-side transition after the caller has |
| 61 | // durably saved the event. It intentionally does not call Store.Save. |
| 62 | func (g *GitRelay) CommitAfterStore(ctx context.Context, e event.Event, repo Repository) error { |
| 63 | return g.commitAfterStore(ctx, e, repo, true) |
| 64 | } |
| 65 | |
| 66 | // CommitAfterStoreNoNotify stages the Git transition without invoking the |
| 67 | // host fanout callback. Use this while already inside the relay publish |
| 68 | // fence; the durable work intent will release visibility afterward. |
| 69 | func (g *GitRelay) CommitAfterStoreNoNotify(ctx context.Context, e event.Event, repo Repository) error { |
| 70 | return g.commitAfterStore(ctx, e, repo, false) |
| 71 | } |
| 72 | |
| 73 | func (g *GitRelay) commitAfterStore(ctx context.Context, e event.Event, repo Repository, notify bool) error { |
| 74 | g.commitMu.Lock() |
| 75 | err := g.stageAfterStore(ctx, e, repo) |
| 76 | pending := g.IsPending(e.ID) |
| 77 | g.commitMu.Unlock() |
| 78 | // The host callback enters its publish fence. Never hold the Git lock |
| 79 | // here: synchronous publication already enters these locks in reverse. |
| 80 | if err == nil && !pending && notify && g.onPromote != nil { |
| 81 | return g.onPromote(ctx, e.ID, repo) |
| 82 | } |
| 83 | return err |
| 84 | } |
| 85 | |
| 86 | func (g *GitRelay) stageAfterStore(ctx context.Context, e event.Event, repo Repository) error { |
| 87 | current, err := g.currentMetadata(ctx, e) |
| 88 | if err != nil { |
| 89 | return err |
| 90 | } |
| 91 | if !current { |
| 92 | // A queued intent may outlive a newer addressable announcement/state. |
| 93 | // Never let that stale intent replace the currently authorized hook. |
| 94 | return nil |
| 95 | } |
| 96 | if e.Kind == 30617 { |
| 97 | // Announcements and ref state are separate addressable events. An |
| 98 | // announcement update must retain the current signed refs and HEAD. |
| 99 | state, found, err := g.store.Latest(ctx, 30618, e.PubKey, event.Tag(e, "d" )) |
| 100 | if err != nil { |
| 101 | return err |
| 102 | } |
| 103 | if found { |
| 104 | stateRepo, err := g.parseRepository(state) |
| 105 | if err != nil { |
| 106 | return err |
| 107 | } |
| 108 | repo.Refs, repo.Head, repo.EventID = stateRepo.Refs, stateRepo.Head, state.ID |
| 109 | } |
| 110 | } |
| 111 | jr := journalRecord{Repository: key(repo.Owner, repo.Identifier), EventID: e.ID, Kind: e.Kind, Refs: repo.Refs, Head: repo.Head} |
| 112 | if err := g.writeJournal(repo, jr); err != nil { |
| 113 | return err |
| 114 | } |
| 115 | g.mu.Lock() |
| 116 | g.repos[key(repo.Owner, repo.Identifier)] = repo |
| 117 | g.mu.Unlock() |
| 118 | if err := g.ensureRepo(repo); err != nil { |
| 119 | return err |
| 120 | } |
| 121 | pending := false |
| 122 | if e.Kind == 30618 { |
| 123 | var err error |
| 124 | if g.stateObjectsPresent(ctx, repo) { |
| 125 | err = g.writeState(repo) |
| 126 | } else { |
| 127 | err = g.writePendingState(repo) |
| 128 | g.mu.Lock() |
| 129 | g.pending[e.ID] = struct{}{} |
| 130 | g.mu.Unlock() |
| 131 | pending = true |
| 132 | } |
| 133 | if err != nil { |
| 134 | return err |
| 135 | } |
| 136 | } |
| 137 | if pending { |
| 138 | return nil |
| 139 | } |
| 140 | if err := g.commitJournal(repo, jr); err != nil { |
| 141 | return err |
| 142 | } |
| 143 | return nil |
| 144 | } |
| 145 | |
| 146 | func (g *GitRelay) currentMetadata(ctx context.Context, e event.Event) (bool, error) { |
| 147 | if e.Kind != 30617 && e.Kind != 30618 { |
| 148 | return true, nil |
| 149 | } |
| 150 | current, found, err := g.store.Latest(ctx, e.Kind, e.PubKey, event.Tag(e, "d" )) |
| 151 | if err != nil || !found { |
| 152 | return false, err |
| 153 | } |
| 154 | return current.ID == e.ID, nil |
| 155 | } |
| 156 | |
| 157 | // IsPending lets the event admission/read layer hide a metadata event until |
| 158 | // its signed Git tips are verified locally. |
| 159 | func (g *GitRelay) IsPending(eventID string) bool { |
| 160 | g.mu.RLock() |
| 161 | _, ok := g.pending[eventID] |
| 162 | g.mu.RUnlock() |
| 163 | return ok |
| 164 | } |
| 165 | |
| 166 | // ExpirePending removes the file and in-memory authorities for state events |
| 167 | // whose retained deadline has elapsed. The SQL event sweep calls this before |
| 168 | // deleting the corresponding event so a stale journal cannot promote it after |
| 169 | // a restart. |
| 170 | func (g *GitRelay) ExpirePending(ctx context.Context, eventIDs []string) error { |
| 171 | if g == nil || len(eventIDs) == 0 { |
| 172 | return nil |
| 173 | } |
| 174 | want := make(map[string]struct{}, len(eventIDs)) |
| 175 | for _, id := range eventIDs { |
| 176 | want[id] = struct{}{} |
| 177 | } |
| 178 | entries, err := os.ReadDir(g.journalDir()) |
| 179 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 180 | return err |
| 181 | } |
| 182 | for _, entry := range entries { |
| 183 | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json" ) { |
| 184 | continue |
| 185 | } |
| 186 | path := filepath.Join(g.journalDir(), entry.Name()) |
| 187 | body, err := os.ReadFile(path) |
| 188 | if err != nil { |
| 189 | return err |
| 190 | } |
| 191 | var jr journalRecord |
| 192 | if err := json.Unmarshal(body, &jr); err != nil { |
| 193 | return err |
| 194 | } |
| 195 | if _, ok := want[jr.EventID]; !ok { |
| 196 | continue |
| 197 | } |
| 198 | owner, identifier, ok := strings.Cut(jr.Repository, "\x00" ) |
| 199 | if ok { |
| 200 | _ = os.Remove(g.pendingStatePath(Repository{Owner: owner, Identifier: identifier})) |
| 201 | _ = os.Remove(g.pendingHeadPath(Repository{Owner: owner, Identifier: identifier})) |
| 202 | } |
| 203 | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 204 | return err |
| 205 | } |
| 206 | } |
| 207 | g.mu.Lock() |
| 208 | for id := range want { |
| 209 | delete(g.pending, id) |
| 210 | } |
| 211 | g.mu.Unlock() |
| 212 | return ctx.Err() |
| 213 | } |
| 214 | |
| 215 | // PruneRef removes an expired native Git ref before its retained metadata is |
| 216 | // deleted. repository may be the storage key or its identifier. |
| 217 | func (g *GitRelay) PruneRef(ctx context.Context, repository, ref string) error { |
| 218 | if g == nil || !validRef(ref) { |
| 219 | return errors.New("git relay: invalid expired ref" ) |
| 220 | } |
| 221 | g.mu.RLock() |
| 222 | var found *Repository |
| 223 | for _, candidate := range g.repos { |
| 224 | if key(candidate.Owner, candidate.Identifier) == repository || candidate.Identifier == repository { |
| 225 | copy := candidate |
| 226 | found = © |
| 227 | break |
| 228 | } |
| 229 | } |
| 230 | g.mu.RUnlock() |
| 231 | if found == nil { |
| 232 | return nil |
| 233 | } |
| 234 | if err := exec.CommandContext(ctx, "git" , "--git-dir" , g.repoPath(*found), "update-ref" , "-d" , ref).Run(); err != nil { |
| 235 | return err |
| 236 | } |
| 237 | g.mu.Lock() |
| 238 | if current, ok := g.repos[key(found.Owner, found.Identifier)]; ok { |
| 239 | delete(current.Refs, ref) |
| 240 | g.repos[key(found.Owner, found.Identifier)] = current |
| 241 | } |
| 242 | g.mu.Unlock() |
| 243 | return nil |
| 244 | } |
| 245 | |
| 246 | // PromotePending verifies and exposes a previously accepted state event. It |
| 247 | // is useful for local receive-pack integrations; ServeHTTP invokes it after a |
| 248 | // smart push as well. |
| 249 | func (g *GitRelay) PromotePending(ctx context.Context, repo Repository) error { |
| 250 | g.commitMu.Lock() |
| 251 | promoted, err := g.promotePending(ctx, repo) |
| 252 | g.commitMu.Unlock() |
| 253 | if err == nil && promoted && g.onPromote != nil { |
| 254 | return g.onPromote(ctx, repo.EventID, repo) |
| 255 | } |
| 256 | return err |
| 257 | } |
| 258 | |
| 259 | func (g *GitRelay) promotePending(ctx context.Context, repo Repository) (bool, error) { |
| 260 | found, err := g.store.Exists(ctx, repo.EventID, 30618) |
| 261 | if err != nil { |
| 262 | return false, err |
| 263 | } |
| 264 | if !found { |
| 265 | return false, nil |
| 266 | } |
| 267 | if !g.stateObjectsPresent(ctx, repo) { |
| 268 | return false, nil |
| 269 | } |
| 270 | g.mu.RLock() |
| 271 | _, tracked := g.pending[repo.EventID] |
| 272 | g.mu.RUnlock() |
| 273 | if _, err := os.Stat(g.pendingStatePath(repo)); err != nil && !tracked { |
| 274 | return false, nil |
| 275 | } |
| 276 | if err := g.writeState(repo); err != nil { |
| 277 | return false, err |
| 278 | } |
| 279 | _ = os.Remove(g.pendingStatePath(repo)) |
| 280 | _ = os.Remove(g.pendingHeadPath(repo)) |
| 281 | g.mu.Lock() |
| 282 | delete(g.pending, repo.EventID) |
| 283 | g.mu.Unlock() |
| 284 | jr := journalRecord{Repository: key(repo.Owner, repo.Identifier), EventID: repo.EventID, Kind: 30618, Refs: repo.Refs, Head: repo.Head} |
| 285 | if err := g.commitJournal(repo, jr); err != nil { |
| 286 | return false, err |
| 287 | } |
| 288 | return true, nil |
| 289 | } |
| 290 | |
| 291 | // Publish validates and admits a signed repository announcement or state |
| 292 | // before saving it and staging the Git transition. Enabled GRASP-06 events |
| 293 | // use their separate admission path. |
| 294 | func (g *GitRelay) Publish(ctx context.Context, e event.Event) error { |
| 295 | if err := event.Validate(e); err != nil { |
| 296 | return err |
| 297 | } |
| 298 | if e.Kind != 30617 && e.Kind != 30618 { |
| 299 | if !((g.grasp06 || g.policyValue().Features.Grasp06) && (e.Kind == 1617 || e.Kind == 1618 || e.Kind == 1619)) { |
| 300 | return fmt.Errorf("unsupported: event kind %d is not GRASP repository metadata" , e.Kind) |
| 301 | } |
| 302 | g.commitMu.Lock() |
| 303 | defer g.commitMu.Unlock() |
| 304 | return g.publishPR(ctx, e) |
| 305 | } |
| 306 | repo, err := g.Validate(ctx, e) |
| 307 | if err != nil { |
| 308 | return err |
| 309 | } |
| 310 | if err := g.store.Save(ctx, e, time.Now().Unix()); err != nil { |
| 311 | return err |
| 312 | } |
| 313 | return g.CommitAfterStore(ctx, e, repo) |
| 314 | } |
| 315 | |
| 316 | // parseRepository resolves parsed metadata against the host's announcement |
| 317 | // and maintainer authority. All shape checks finish before consulting the host. |
| 318 | func (g *GitRelay) parseRepository(e event.Event) (Repository, error) { |
| 319 | m, err := ParseMetadata(e) |
| 320 | if err != nil { |
| 321 | return Repository{}, err |
| 322 | } |
| 323 | state := Repository{Owner: m.Author, Identifier: m.Identifier, EventID: m.EventID, |
| 324 | Private: m.Private, Clone: m.Clone, Relays: m.Relays, Maintainers: m.Maintainers, |
| 325 | Refs: m.Refs, Head: m.Head} |
| 326 | if m.Kind == 30617 { |
| 327 | return state, nil |
| 328 | } |
| 329 | // State is replaceable: bind it to the current announcement owner and |
| 330 | // carry the announcement's identity into the hook/state file. |
| 331 | id := m.Identifier |
| 332 | g.mu.RLock() |
| 333 | ann := g.repos[key(e.PubKey, id)] |
| 334 | g.mu.RUnlock() |
| 335 | if ann.Owner == "" { |
| 336 | q, err := g.store.Query(context.Background(), event.Filter{Kinds: []int{30617}, Tags: map[string][]string{"d" : {id}}}, time.Now().Unix(), 0) |
| 337 | if err != nil { |
| 338 | return Repository{}, err |
| 339 | } |
| 340 | if len(q) == 0 { |
| 341 | return Repository{}, errors.New("blocked: repository announcement is missing" ) |
| 342 | } |
| 343 | for _, candidate := range q { |
| 344 | ann, err = g.parseRepository(candidate) |
| 345 | if err == nil && g.IsMaintainer(context.Background(), ann, e.PubKey) { |
| 346 | break |
| 347 | } |
| 348 | ann = Repository{} |
| 349 | } |
| 350 | if ann.Owner == "" { |
| 351 | return Repository{}, errors.New("blocked: state author is not an accepted repository maintainer" ) |
| 352 | } |
| 353 | } |
| 354 | if !g.IsMaintainer(context.Background(), ann, e.PubKey) { |
| 355 | return Repository{}, errors.New("blocked: state author is not an accepted repository maintainer" ) |
| 356 | } |
| 357 | state.Owner, state.Private = ann.Owner, ann.Private |
| 358 | state.Clone = append([]string(nil), ann.Clone...) |
| 359 | state.Relays = append([]string(nil), ann.Relays...) |
| 360 | state.Maintainers = append([]string(nil), ann.Maintainers...) |
| 361 | state.EventID = e.ID |
| 362 | return state, nil |
| 363 | } |
| 364 | |
| 365 | // validateRefs preserves the internal caller boundary for synchronization. |
| 366 | func (g *GitRelay) validateRefs(refs map[string]string) error { return validateRefs(refs) } |
| 367 | |
| 368 | // ErrIncomplete reports a synchronization pass that saved progress and has |
| 369 | // more history to fetch. The scheduler continues it soon without counting a |
| 370 | // failure. |
| 371 | var ErrIncomplete = errors.New("synchronization incomplete" ) |
| 372 | |
| 373 | // Capabilities returns only protocol surfaces that this instance can serve. |
| 374 | func (g *GitRelay) Capabilities() []string { |
| 375 | p := g.policyValue() |
| 376 | c := []string{"GRASP-01" } |
| 377 | if p.Features.Grasp02 && g.eventSync != nil { |
| 378 | c = append(c, "GRASP-02" ) |
| 379 | if p.Features.Grasp03 { |
| 380 | c = append(c, "GRASP-03" ) |
| 381 | } |
| 382 | } |
| 383 | if g.grasp06 || p.Features.Grasp06 { |
| 384 | c = append(c, "GRASP-06" ) |
| 385 | } |
| 386 | if p.PrivateServiceEnabled() { |
| 387 | c = append(c, "GRASP-08" ) |
| 388 | } |
| 389 | return c |
| 390 | } |
| 391 | |
| 392 | // SupportedGRASPs is the NIP-11-facing capability list. It deliberately |
| 393 | // mirrors Capabilities so callers cannot advertise preview protocols by hand. |
| 394 | func (g *GitRelay) SupportedGRASPs() []string { return g.Capabilities() } |
| 395 | |
| 396 | // DeletePRRef removes a temporary GRASP-06 ref before its Nostr metadata is |
| 397 | // deleted. Identity accepts the source coordinates used by delivery jobs: |
| 398 | // pr:<owner>:<identifier> or 30617:<owner>:<identifier>. |
| 399 | func (g *GitRelay) DeletePRRef(ctx context.Context, identity, ref string) error { |
| 400 | parts := strings.SplitN(identity, ":" , 3) |
| 401 | if len(parts) != 3 || (parts[0] != "pr" && parts[0] != "30617" ) || !isPubKey(parts[1]) || parts[2] == "" || strings.ContainsAny(parts[2], `/\\` ) { |
| 402 | return errors.New("invalid: PR repository identity" ) |
| 403 | } |
| 404 | if !strings.HasPrefix(ref, "refs/nostr/" ) || len(strings.TrimPrefix(ref, "refs/nostr/" )) != 64 { |
| 405 | return errors.New("invalid: PR ref" ) |
| 406 | } |
| 407 | r := Repository{Owner: parts[1], Identifier: parts[2], Alternative: true, Refs: map[string]string{}} |
| 408 | if err := g.ensureRepo(r); err != nil { |
| 409 | return err |
| 410 | } |
| 411 | cmd := exec.CommandContext(ctx, "git" , "--git-dir" , g.repoPath(r), "update-ref" , "-d" , ref) |
| 412 | if out, err := cmd.CombinedOutput(); err != nil { |
| 413 | return fmt.Errorf("delete PR ref: %w: %s" , err, strings.TrimSpace(string(out))) |
| 414 | } |
| 415 | return g.removeRefFromPending(r, ref) |
| 416 | } |
| 417 | |
| 418 | func (g *GitRelay) removeRefFromPending(r Repository, ref string) error { |
| 419 | b, err := os.ReadFile(g.pendingStatePath(r)) |
| 420 | if errors.Is(err, os.ErrNotExist) { |
| 421 | return nil |
| 422 | } |
| 423 | if err != nil { |
| 424 | return err |
| 425 | } |
| 426 | var kept map[string]string = map[string]string{} |
| 427 | for _, line := range strings.Split(string(b), "\n" ) { |
| 428 | fields := strings.Fields(line) |
| 429 | if len(fields) == 2 && fields[0] != ref { |
| 430 | kept[fields[0]] = fields[1] |
| 431 | } |
| 432 | } |
| 433 | if len(kept) == 0 { |
| 434 | return os.Remove(g.pendingStatePath(r)) |
| 435 | } |
| 436 | return g.writeRefsFile(g.pendingStatePath(r), kept) |
| 437 | } |
| 438 | |
| 439 | // Reload rebuilds repository and pending indexes after a restore has inserted |
| 440 | // events and native Git repositories on disk. |
| 441 | func (g *GitRelay) Reload(ctx context.Context) error { |
| 442 | g.commitMu.Lock() |
| 443 | defer g.commitMu.Unlock() |
| 444 | g.repoConfigMu.Lock() |
| 445 | g.configured = nil |
| 446 | g.repoConfigMu.Unlock() |
| 447 | if err := g.recoverJournals(); err != nil { |
| 448 | return err |
| 449 | } |
| 450 | q, err := g.store.Query(ctx, event.Filter{Kinds: []int{30617}}, time.Now().Unix(), 0) |
| 451 | if err != nil { |
| 452 | return err |
| 453 | } |
| 454 | repos := make(map[string]Repository) |
| 455 | for _, raw := range q { |
| 456 | r, parseErr := g.parseRepository(raw) |
| 457 | if parseErr == nil { |
| 458 | repos[key(r.Owner, r.Identifier)] = r |
| 459 | } |
| 460 | } |
| 461 | // Recover only native refs that match the published signed authority. |
| 462 | // Incoming receive-pack refs may still be awaiting promotion. |
| 463 | for coordinate, announcement := range repos { |
| 464 | if err := g.ensureRepo(announcement); err != nil { |
| 465 | return err |
| 466 | } |
| 467 | refs, refsErr := g.publishedNativeRefs(announcement) |
| 468 | if refsErr != nil { |
| 469 | return refsErr |
| 470 | } |
| 471 | if len(refs) > 0 { |
| 472 | announcement.Refs = refs |
| 473 | if head, headErr := exec.Command("git" , "--git-dir" , g.repoPath(announcement), "symbolic-ref" , "-q" , "HEAD" ).Output(); headErr == nil { |
| 474 | announcement.Head = "ref: " + strings.TrimSpace(string(head)) |
| 475 | } |
| 476 | repos[coordinate] = announcement |
| 477 | } |
| 478 | } |
| 479 | // If no native refs exist (for example an older store written before native |
| 480 | // projection), recover the newest complete signed state as a fallback. |
| 481 | for coordinate, announcement := range repos { |
| 482 | if _, err := os.Stat(g.statePath(announcement)); err == nil { |
| 483 | continue |
| 484 | } else if !errors.Is(err, os.ErrNotExist) { |
| 485 | return err |
| 486 | } |
| 487 | if _, err := os.Stat(g.pendingStatePath(announcement)); err == nil { |
| 488 | continue |
| 489 | } else if !errors.Is(err, os.ErrNotExist) { |
| 490 | return err |
| 491 | } |
| 492 | states, stateErr := g.store.Query(ctx, event.Filter{Authors: append([]string{announcement.Owner}, announcement.Maintainers...), Kinds: []int{30618}, Tags: map[string][]string{"d" : {announcement.Identifier}}}, time.Now().Unix(), 0) |
| 493 | if stateErr != nil { |
| 494 | return stateErr |
| 495 | } |
| 496 | if len(states) == 0 { |
| 497 | continue |
| 498 | } |
| 499 | sort.Slice(states, func(i, j int) bool { return states[i].CreatedAt > states[j].CreatedAt }) |
| 500 | state, parseErr := g.parseRepository(states[0]) |
| 501 | if parseErr != nil || state.Owner != announcement.Owner || !g.stateObjectsPresent(ctx, state) { |
| 502 | continue |
| 503 | } |
| 504 | repos[coordinate] = state |
| 505 | } |
| 506 | prs, prErr := g.store.Query(ctx, event.Filter{Kinds: []int{1617, 1618, 1619}}, time.Now().Unix(), 0) |
| 507 | if prErr != nil { |
| 508 | return prErr |
| 509 | } |
| 510 | for _, raw := range prs { |
| 511 | identifier := event.Tag(raw, "d" ) |
| 512 | if identifier == "" { |
| 513 | identifier = event.Tag(raw, "a" ) |
| 514 | } |
| 515 | commit := event.Tag(raw, "c" ) |
| 516 | if !validIdentifier(identifier) || !isObjectID(commit) { |
| 517 | continue |
| 518 | } |
| 519 | pr := Repository{Owner: raw.PubKey, Identifier: identifier, EventID: raw.ID, Alternative: true, Refs: map[string]string{}} |
| 520 | if _, err := os.Stat(g.repoPath(pr)); errors.Is(err, os.ErrNotExist) { |
| 521 | continue |
| 522 | } else if err != nil { |
| 523 | return err |
| 524 | } |
| 525 | if refs, refsErr := g.publishedNativeRefs(pr); refsErr == nil && len(refs) > 0 { |
| 526 | pr.Refs = refs |
| 527 | } |
| 528 | repos[key(pr.Owner, pr.Identifier)] = pr |
| 529 | } |
| 530 | entries, err := os.ReadDir(g.journalDir()) |
| 531 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 532 | return err |
| 533 | } |
| 534 | refreshedPending := make(map[string]struct{}) |
| 535 | for _, entry := range entries { |
| 536 | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json" ) { |
| 537 | continue |
| 538 | } |
| 539 | b, readErr := os.ReadFile(filepath.Join(g.journalDir(), entry.Name())) |
| 540 | if readErr != nil { |
| 541 | return readErr |
| 542 | } |
| 543 | var jr journalRecord |
| 544 | if json.Unmarshal(b, &jr) == nil && jr.EventID != "" { |
| 545 | refreshedPending[jr.EventID] = struct{}{} |
| 546 | } |
| 547 | } |
| 548 | g.mu.Lock() |
| 549 | g.repos = repos |
| 550 | g.pending = refreshedPending |
| 551 | g.mu.Unlock() |
| 552 | return nil |
| 553 | } |
| 554 | |
| 555 | func (g *GitRelay) publishPR(ctx context.Context, e event.Event) error { |
| 556 | id := event.Tag(e, "d" ) |
| 557 | if id == "" { |
| 558 | id = event.Tag(e, "a" ) |
| 559 | } |
| 560 | if !validIdentifier(id) { |
| 561 | return errors.New("invalid: PR repository identifier" ) |
| 562 | } |
| 563 | r := Repository{Owner: e.PubKey, Identifier: id, EventID: e.ID, Alternative: true, Refs: map[string]string{}} |
| 564 | commit := event.Tag(e, "c" ) |
| 565 | if commit != "" && !isObjectID(commit) { |
| 566 | return errors.New("invalid: PR commit is not a SHA-1" ) |
| 567 | } |
| 568 | if commit != "" { |
| 569 | r.Refs["refs/nostr/" +e.ID] = commit |
| 570 | } |
| 571 | if g.authorize != nil { |
| 572 | if err := g.authorize(ctx, e, r); err != nil { |
| 573 | return err |
| 574 | } |
| 575 | } |
| 576 | if err := g.writeJournal(r, journalRecord{Repository: key(r.Owner, r.Identifier), EventID: e.ID, Kind: e.Kind, Refs: r.Refs, Head: r.Head}); err != nil { |
| 577 | return err |
| 578 | } |
| 579 | if err := g.store.Save(ctx, e, time.Now().Unix()); err != nil { |
| 580 | return err |
| 581 | } |
| 582 | if err := g.ensureRepo(r); err != nil { |
| 583 | return err |
| 584 | } |
| 585 | g.mu.Lock() |
| 586 | g.repos[key(r.Owner, r.Identifier)] = r |
| 587 | g.mu.Unlock() |
| 588 | pending := !g.stateObjectsPresent(ctx, r) |
| 589 | if !pending { |
| 590 | if err := g.writeState(r); err != nil { |
| 591 | return err |
| 592 | } |
| 593 | } else { |
| 594 | if err := g.writePendingState(r); err != nil { |
| 595 | return err |
| 596 | } |
| 597 | g.mu.Lock() |
| 598 | g.pending[e.ID] = struct{}{} |
| 599 | g.mu.Unlock() |
| 600 | } |
| 601 | if pending { |
| 602 | return nil |
| 603 | } |
| 604 | return g.commitJournal(r, journalRecord{Repository: key(r.Owner, r.Identifier), EventID: e.ID, Kind: e.Kind, Refs: r.Refs, Head: r.Head}) |
| 605 | } |