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