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