Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

Detect Authorized Integrations and rely on them when configured

authint.go's HasAuthorizedIntegration scans a Forgejo repo's
.forgejo/workflows/*.yml for a job with enable-openid-connect: true that
references another pair's host — the only signal available for this
without admin API access to the trust relationship itself.

At startup, detectAuthorizedIntegrations checks every pair of Forgejo
hosts sharing a series. When one side already has a workflow pushing
directly into the other, GitSyncer.AuthorizedIntegrationSource tells the
receiving pair to stop relaying that content via Radicle itself — the
direct CI path delivers it faster anyway. Genuine divergences still get
reported exactly as before; only the two unambiguous "the other side is
simply behind" cases are skipped. No Forgejo host in the current config
has such a workflow, so this is detected-but-inactive today, exactly as
it should be.

Also: dashboard label "N mirrored sides" -> "N mirrors".

+164 -1
+34
cmd/sync/main.go
··· 49 49 syncers := make([]*sync.RepoSyncer, 0, len(cfg.Repos)) 50 50 syncersByPair := map[string]*sync.RepoSyncer{} 51 51 syncersBySeries := map[string]*sync.RepoSyncer{} 52 + pairsBySeries := map[string][]*sync.RepoSyncer{} 52 53 for _, pair := range cfg.Repos { 53 54 rs, err := sync.New(pair, st, sync.WorkDirFor(stateDir, pair.Name)) 54 55 if err != nil { ··· 60 61 if _, ok := syncersBySeries[rs.Series()]; !ok { 61 62 syncersBySeries[rs.Series()] = rs 62 63 } 64 + pairsBySeries[rs.Series()] = append(pairsBySeries[rs.Series()], rs) 63 65 } 66 + detectAuthorizedIntegrations(pairsBySeries, log) 64 67 65 68 topology := buildTopology(cfg) 66 69 blueskyConfigured := map[string]bool{} ··· 189 192 add(series, rHost, sync.RadicleExplorerLink(pair.Radicle)) 190 193 } 191 194 return topology 195 + } 196 + 197 + // detectAuthorizedIntegrations checks, for every pair of Forgejo hosts 198 + // sharing a series, whether one side already has a Forgejo Actions 199 + // workflow pushing directly into the other via Authorized Integrations 200 + // (see internal/sync's authint.go) — and if so, tells the receiving 201 + // pair's GitSyncer to stop relaying that content via Radicle itself, 202 + // since the direct path is faster. Run once at startup: workflow files 203 + // don't change often enough to justify re-checking every pass. 204 + func detectAuthorizedIntegrations(pairsBySeries map[string][]*sync.RepoSyncer, log *slog.Logger) { 205 + for series, pairs := range pairsBySeries { 206 + if len(pairs) < 2 { 207 + continue 208 + } 209 + for _, source := range pairs { 210 + for _, dest := range pairs { 211 + if source == dest { 212 + continue 213 + } 214 + destHost := dest.ForgejoHost() 215 + if destHost == "" { 216 + continue 217 + } 218 + if sync.HasAuthorizedIntegration(source.ForgejoClient(), destHost) { 219 + log.Info("authorized integration detected", 220 + "series", series, "source", source.ForgejoHost(), "target", destHost) 221 + dest.SetAuthorizedIntegrationSource(source.ForgejoHost()) 222 + } 223 + } 224 + } 225 + } 192 226 } 193 227 194 228 func lastSlash(s string) int {
+48
internal/forgejo/client.go
··· 5 5 6 6 import ( 7 7 "bytes" 8 + "encoding/base64" 8 9 "encoding/json" 9 10 "fmt" 10 11 "io" ··· 153 154 req := map[string]string{"body": body} 154 155 return c.do(http.MethodPost, path, req, nil) 155 156 } 157 + 158 + // contentEntry is the subset of Forgejo's contents API response this 159 + // client reads, shared by both directory listings and single-file fetches. 160 + type contentEntry struct { 161 + Name string `json:"name"` 162 + Type string `json:"type"` // "file" or "dir" 163 + Content string `json:"content,omitempty"` 164 + Encoding string `json:"encoding,omitempty"` 165 + } 166 + 167 + // ListWorkflowFiles lists file names directly under .forgejo/workflows/. 168 + // Returns an empty slice (not an error) if the directory doesn't exist — 169 + // most repos have no workflows at all, which is a normal, expected state. 170 + func (c *Client) ListWorkflowFiles() ([]string, error) { 171 + var entries []contentEntry 172 + path := fmt.Sprintf("/repos/%s/%s/contents/.forgejo/workflows", c.owner, c.repo) 173 + if err := c.do(http.MethodGet, path, nil, &entries); err != nil { 174 + if strings.Contains(err.Error(), "HTTP 404") { 175 + return nil, nil 176 + } 177 + return nil, err 178 + } 179 + names := make([]string, 0, len(entries)) 180 + for _, e := range entries { 181 + if e.Type == "file" { 182 + names = append(names, e.Name) 183 + } 184 + } 185 + return names, nil 186 + } 187 + 188 + // GetFileContent fetches and decodes one file's text content. 189 + func (c *Client) GetFileContent(path string) (string, error) { 190 + var entry contentEntry 191 + apiPath := fmt.Sprintf("/repos/%s/%s/contents/%s", c.owner, c.repo, path) 192 + if err := c.do(http.MethodGet, apiPath, nil, &entry); err != nil { 193 + return "", err 194 + } 195 + if entry.Encoding == "base64" { 196 + decoded, err := base64.StdEncoding.DecodeString(entry.Content) 197 + if err != nil { 198 + return "", fmt.Errorf("decode %s: %w", path, err) 199 + } 200 + return string(decoded), nil 201 + } 202 + return entry.Content, nil 203 + }
+1 -1
internal/status/status.go
··· 711 711 <summary class="series-label"> 712 712 <span class="dot{{if not .OK}} bad{{end}}"></span> 713 713 <span class="series-name">{{.Name}}</span> 714 - <span class="series-count">{{len .SubRows}} mirrored side{{if ne (len .SubRows) 1}}s{{end}}</span> 714 + <span class="series-count">{{len .SubRows}} mirror{{if ne (len .SubRows) 1}}s{{end}}</span> 715 715 </summary> 716 716 <div class="subrows"> 717 717 {{range .SubRows}}
+36
internal/sync/authint.go
··· 1 + package sync 2 + 3 + import ( 4 + "strings" 5 + 6 + "graft/internal/forgejo" 7 + ) 8 + 9 + // HasAuthorizedIntegration reports whether fc's repository has a Forgejo 10 + // Actions workflow configured to push directly to targetHost via 11 + // Authorized Integrations (a job with `enable-openid-connect: true` whose 12 + // body references targetHost). Detected by scanning 13 + // .forgejo/workflows/*.yml — there's no admin API to query the trust 14 + // relationship itself, so this is the only signal available from a 15 + // regular repo token's perspective. False on any error (missing 16 + // directory, no access, ...): absence of evidence is treated as "not 17 + // configured", never as a reason to fail the sync. 18 + func HasAuthorizedIntegration(fc *forgejo.Client, targetHost string) bool { 19 + names, err := fc.ListWorkflowFiles() 20 + if err != nil { 21 + return false 22 + } 23 + for _, name := range names { 24 + if !strings.HasSuffix(name, ".yml") && !strings.HasSuffix(name, ".yaml") { 25 + continue 26 + } 27 + content, err := fc.GetFileContent(".forgejo/workflows/" + name) 28 + if err != nil { 29 + continue 30 + } 31 + if strings.Contains(content, "enable-openid-connect") && strings.Contains(content, targetHost) { 32 + return true 33 + } 34 + } 35 + return false 36 + }
+19
internal/sync/git.go
··· 35 35 Series string // dashboard row this pair's activity groups under 36 36 SeriesURL string // where clicking that row's name goes 37 37 Bluesky *BlueskyPoster // nil unless this pair has bluesky configured 38 + // AuthorizedIntegrationSource, if set, names another pair's Forgejo 39 + // host that already pushes new commits directly into this pair's 40 + // Forgejo via Authorized Integrations (see authint.go) — faster than 41 + // this pair waiting for the next poll to relay it via Radicle. When 42 + // set, Sync skips its own rad -> forgejo push and just observes 43 + // whether the content has already arrived, falling back to pushing it 44 + // itself only once a real divergence would otherwise be reported. 45 + AuthorizedIntegrationSource string 38 46 } 39 47 40 48 func (g *GitSyncer) run(args ...string) (string, error) { ··· 159 167 160 168 case forgejoHead == "" || forgejoHead == lastForgejo: 161 169 // Forgejo hasn't moved: rad is ahead. 170 + if g.AuthorizedIntegrationSource != "" { 171 + // Someone else's CI already pushes directly into this 172 + // Forgejo (see AuthorizedIntegrationSource's doc comment) — 173 + // faster than waiting for us to relay it via Radicle. 174 + // Nothing to do; next pass sees forgejoHead == radHead once 175 + // it arrives, same as any other convergence. 176 + break 177 + } 162 178 if err := g.pushBranch("rad", "forgejo", forgejoHead, state.RadicleToForgejo); err != nil { 163 179 return fmt.Errorf("mirror rad -> forgejo: %w", err) 164 180 } ··· 172 188 173 189 case g.isAncestor(forgejoHead, radHead): 174 190 // Symmetric case: forgejo's tip is contained in rad's. 191 + if g.AuthorizedIntegrationSource != "" { 192 + break 193 + } 175 194 if err := g.pushBranch("rad", "forgejo", forgejoHead, state.RadicleToForgejo); err != nil { 176 195 return fmt.Errorf("mirror rad -> forgejo: %w", err) 177 196 }
+26
internal/sync/syncer.go
··· 126 126 // Name is this pair's name, as given in the config. 127 127 func (rs *RepoSyncer) Name() string { return rs.pair.Name } 128 128 129 + // ForgejoClient exposes this pair's Forgejo client for cross-pair 130 + // detection helpers (see HasAuthorizedIntegration) that need to make API 131 + // calls outside the normal sync flow. 132 + func (rs *RepoSyncer) ForgejoClient() *forgejo.Client { return rs.forgejo } 133 + 134 + // ForgejoHost is the host this pair's Forgejo repo lives on, e.g. 135 + // "f1.cyberwild.org". 136 + func (rs *RepoSyncer) ForgejoHost() string { 137 + u, err := url.Parse(rs.pair.Forgejo.BaseURL) 138 + if err != nil { 139 + return "" 140 + } 141 + return u.Host 142 + } 143 + 144 + // SetAuthorizedIntegrationSource declares that another pair's Forgejo host 145 + // already pushes directly into this pair's Forgejo via Authorized 146 + // Integrations (see authint.go and GitSyncer.AuthorizedIntegrationSource). 147 + // Set once, after every pair in a series is known, from cmd/sync/main.go — 148 + // a single pair's own construction can't see its siblings. 149 + func (rs *RepoSyncer) SetAuthorizedIntegrationSource(host string) { 150 + if rs.git != nil { 151 + rs.git.AuthorizedIntegrationSource = host 152 + } 153 + } 154 + 129 155 // Series is the dashboard row this pair's activity groups under: its 130 156 // configured series, or its own name if unset. 131 157 func (rs *RepoSyncer) Series() string {