project.go
1 // SPDX-FileCopyrightText: Amolith <amolith@secluded.site> 2 // 3 // SPDX-License-Identifier: Apache-2.0 4 5 package project 6 7 import ( 8 "crypto/sha256" 9 "database/sql" 10 "errors" 11 "fmt" 12 "log" 13 "sort" 14 "strings" 15 "sync" 16 "time" 17 18 "github.com/unascribed/FlexVer/go/flexver" 19 20 "git.sr.ht/~amolith/willow/db" 21 "git.sr.ht/~amolith/willow/git" 22 "git.sr.ht/~amolith/willow/rss" 23 ) 24 25 type Project struct { 26 ID string 27 URL string 28 Name string 29 Forge string 30 Running string 31 Releases []Release 32 } 33 34 type Release struct { 35 ID string 36 ProjectID string 37 URL string 38 Tag string 39 Content string 40 Date time.Time 41 } 42 43 // GetReleases returns a list of all releases for a project from the database 44 func GetReleases(dbConn *sql.DB, mu *sync.Mutex, proj Project) (Project, error) { 45 proj.ID = GenProjectID(proj.URL, proj.Name, proj.Forge) 46 47 ret, err := db.GetReleases(dbConn, proj.ID) 48 if err != nil { 49 return proj, err 50 } 51 52 if len(ret) == 0 { 53 return fetchReleases(dbConn, mu, proj) 54 } 55 56 for _, row := range ret { 57 proj.Releases = append(proj.Releases, Release{ 58 ID: row["id"], 59 ProjectID: proj.ID, 60 Tag: row["tag"], 61 Content: row["content"], 62 URL: row["release_url"], 63 Date: time.Time{}, 64 }) 65 } 66 proj.Releases = SortReleases(proj.Releases) 67 return proj, nil 68 } 69 70 // fetchReleases fetches releases from a project's forge given its URI 71 func fetchReleases(dbConn *sql.DB, mu *sync.Mutex, p Project) (Project, error) { 72 var err error 73 switch p.Forge { 74 case "github", "gitea", "forgejo": 75 rssReleases, err := rss.GetReleases(p.URL) 76 if err != nil { 77 fmt.Println("Error getting RSS releases:", err) 78 return p, err 79 } 80 for _, release := range rssReleases { 81 p.Releases = append(p.Releases, Release{ 82 ID: GenReleaseID(p.URL, release.URL, release.Tag), 83 Tag: release.Tag, 84 Content: release.Content, 85 URL: release.URL, 86 Date: release.Date, 87 }) 88 err = upsertReleases(dbConn, mu, p.ID, p.Releases) 89 if err != nil { 90 log.Printf("Error upserting release: %v", err) 91 return p, err 92 } 93 } 94 default: 95 gitReleases, err := git.GetReleases(p.URL, p.Forge) 96 if err != nil { 97 return p, err 98 } 99 for _, release := range gitReleases { 100 p.Releases = append(p.Releases, Release{ 101 ID: GenReleaseID(p.URL, release.URL, release.Tag), 102 Tag: release.Tag, 103 Content: release.Content, 104 URL: release.URL, 105 Date: release.Date, 106 }) 107 err = upsertReleases(dbConn, mu, p.ID, p.Releases) 108 if err != nil { 109 log.Printf("Error upserting release: %v", err) 110 return p, err 111 } 112 } 113 } 114 p.Releases = SortReleases(p.Releases) 115 return p, err 116 } 117 118 func SortReleases(releases []Release) []Release { 119 sort.Slice(releases, func(i, j int) bool { 120 return !flexver.Less(releases[i].Tag, releases[j].Tag) 121 }) 122 return releases 123 } 124 125 func SortProjects(projects []Project) []Project { 126 sort.Slice(projects, func(i, j int) bool { 127 return strings.ToLower(projects[i].Name) < strings.ToLower(projects[j].Name) 128 }) 129 return projects 130 } 131 132 // upsertReleases updates or inserts a release in the database 133 func upsertReleases(dbConn *sql.DB, mu *sync.Mutex, projID string, releases []Release) error { 134 for _, release := range releases { 135 date := release.Date.Format("2006-01-02 15:04:05") 136 err := db.UpsertRelease(dbConn, mu, release.ID, projID, release.URL, release.Tag, release.Content, date) 137 if err != nil { 138 log.Printf("Error upserting release: %v", err) 139 return err 140 } 141 } 142 return nil 143 } 144 145 // GenReleaseID generates a likely-unique ID from its project's URL, its release's URL, and its tag 146 func GenReleaseID(projectURL, releaseURL, tag string) string { 147 idByte := sha256.Sum256([]byte(projectURL + releaseURL + tag)) 148 return fmt.Sprintf("%x", idByte) 149 } 150 151 // GenProjectID generates a likely-unique ID from a project's URI, name, and forge 152 func GenProjectID(url, name, forge string) string { 153 idByte := sha256.Sum256([]byte(url + name + forge)) 154 return fmt.Sprintf("%x", idByte) 155 } 156 157 func Track(dbConn *sql.DB, mu *sync.Mutex, manualRefresh *chan struct{}, name, url, forge, release string) { 158 id := GenProjectID(url, name, forge) 159 err := db.UpsertProject(dbConn, mu, id, url, name, forge, release) 160 if err != nil { 161 fmt.Println("Error upserting project:", err) 162 } 163 *manualRefresh <- struct{}{} 164 } 165 166 func Untrack(dbConn *sql.DB, mu *sync.Mutex, id string) { 167 proj, err := db.GetProject(dbConn, id) 168 if err != nil { 169 fmt.Println("Error getting project:", err) 170 } 171 172 err = db.DeleteProject(dbConn, mu, proj["id"]) 173 if err != nil { 174 fmt.Println("Error deleting project:", err) 175 } 176 177 // TODO: before removing, check whether other tracked projects use the same 178 // repo 179 err = git.RemoveRepo(proj["url"]) 180 if err != nil { 181 log.Println(err) 182 } 183 } 184 185 func RefreshLoop(dbConn *sql.DB, mu *sync.Mutex, interval int, manualRefresh, req *chan struct{}, res *chan []Project) { 186 ticker := time.NewTicker(time.Second * time.Duration(interval)) 187 188 fetch := func() []Project { 189 projectsList, err := GetProjects(dbConn) 190 if err != nil { 191 fmt.Println("Error getting projects:", err) 192 } 193 for i, p := range projectsList { 194 p, err := fetchReleases(dbConn, mu, p) 195 if err != nil { 196 fmt.Println(err) 197 continue 198 } 199 projectsList[i] = p 200 } 201 sort.Slice(projectsList, func(i, j int) bool { 202 return strings.ToLower(projectsList[i].Name) < strings.ToLower(projectsList[j].Name) 203 }) 204 for i := range projectsList { 205 err = upsertReleases(dbConn, mu, projectsList[i].ID, projectsList[i].Releases) 206 if err != nil { 207 fmt.Println("Error upserting release:", err) 208 continue 209 } 210 } 211 return projectsList 212 } 213 214 projects := fetch() 215 216 for { 217 select { 218 case <-ticker.C: 219 projects = fetch() 220 case <-*manualRefresh: 221 ticker.Reset(time.Second * 3600) 222 projects = fetch() 223 case <-*req: 224 projectsCopy := make([]Project, len(projects)) 225 copy(projectsCopy, projects) 226 *res <- projectsCopy 227 } 228 } 229 } 230 231 // GetProject returns a project from the database 232 func GetProject(dbConn *sql.DB, proj Project) (Project, error) { 233 projectDB, err := db.GetProject(dbConn, proj.ID) 234 if err != nil && errors.Is(err, sql.ErrNoRows) { 235 return proj, nil 236 } else if err != nil { 237 return proj, err 238 } 239 p := Project{ 240 ID: proj.ID, 241 URL: proj.URL, 242 Name: proj.Name, 243 Forge: proj.Forge, 244 Running: projectDB["version"], 245 } 246 return p, err 247 } 248 249 // GetProjectWithReleases returns a single project from the database along with its releases 250 func GetProjectWithReleases(dbConn *sql.DB, mu *sync.Mutex, proj Project) (Project, error) { 251 project, err := GetProject(dbConn, proj) 252 if err != nil { 253 return Project{}, err 254 } 255 256 return GetReleases(dbConn, mu, project) 257 } 258 259 // GetProjects returns a list of all projects from the database 260 func GetProjects(dbConn *sql.DB) ([]Project, error) { 261 projectsDB, err := db.GetProjects(dbConn) 262 if err != nil { 263 return nil, err 264 } 265 266 projects := make([]Project, len(projectsDB)) 267 for i, p := range projectsDB { 268 projects[i] = Project{ 269 ID: p["id"], 270 URL: p["url"], 271 Name: p["name"], 272 Forge: p["forge"], 273 Running: p["version"], 274 } 275 } 276 277 return SortProjects(projects), nil 278 } 279 280 // GetProjectsWithReleases returns a list of all projects and all their releases 281 // from the database 282 func GetProjectsWithReleases(dbConn *sql.DB, mu *sync.Mutex) ([]Project, error) { 283 projects, err := GetProjects(dbConn) 284 if err != nil { 285 return nil, err 286 } 287 288 for i := range projects { 289 projects[i], err = GetReleases(dbConn, mu, projects[i]) 290 if err != nil { 291 return nil, err 292 } 293 projects[i].Releases = SortReleases(projects[i].Releases) 294 } 295 296 return SortProjects(projects), nil 297 }