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
133// upsertReleases updates or inserts a release in the database
134func upsertReleases(dbConn *sql.DB, mu *sync.Mutex, projID string, releases []Release) error {
135 for _, release := range releases {
136 date := release.Date.Format("2006-01-02 15:04:05")
137 err := db.UpsertRelease(dbConn, mu, release.ID, projID, release.URL, release.Tag, release.Content, date)
138 if err != nil {
139 log.Printf("Error upserting release: %v", err)
140 return err
141 }
142 }
143 return nil
144}
145
146// GenReleaseID generates a likely-unique ID from its project's URL, its release's URL, and its tag
147func GenReleaseID(projectURL, releaseURL, tag string) string {
148 idByte := sha256.Sum256([]byte(projectURL + releaseURL + tag))
149 return fmt.Sprintf("%x", idByte)
150}
151
152// GenProjectID generates a likely-unique ID from a project's URI, name, and forge
153func GenProjectID(url, name, forge string) string {
154 idByte := sha256.Sum256([]byte(url + name + forge))
155 return fmt.Sprintf("%x", idByte)
156}
157
158func Track(dbConn *sql.DB, mu *sync.Mutex, manualRefresh *chan struct{}, name, url, forge, release string) {
159 id := GenProjectID(url, name, forge)
160 err := db.UpsertProject(dbConn, mu, id, url, name, forge, release)
161 if err != nil {
162 fmt.Println("Error upserting project:", err)
163 }
164 *manualRefresh <- struct{}{}
165}
166
167func Untrack(dbConn *sql.DB, mu *sync.Mutex, id string) {
168 err := db.DeleteProject(dbConn, mu, id)
169 if err != nil {
170 fmt.Println("Error deleting project:", err)
171 }
172
173 err = git.RemoveRepo(id)
174 if err != nil {
175 log.Println(err)
176 }
177}
178
179func RefreshLoop(dbConn *sql.DB, mu *sync.Mutex, interval int, manualRefresh, req *chan struct{}, res *chan []Project) {
180 ticker := time.NewTicker(time.Second * time.Duration(interval))
181
182 fetch := func() []Project {
183 projectsList, err := GetProjects(dbConn)
184 if err != nil {
185 fmt.Println("Error getting projects:", err)
186 }
187 for i, p := range projectsList {
188 p, err := fetchReleases(dbConn, mu, p)
189 if err != nil {
190 fmt.Println(err)
191 continue
192 }
193 projectsList[i] = p
194 }
195 sort.Slice(projectsList, func(i, j int) bool {
196 return strings.ToLower(projectsList[i].Name) < strings.ToLower(projectsList[j].Name)
197 })
198 for i := range projectsList {
199 err = upsertReleases(dbConn, mu, projectsList[i].ID, projectsList[i].Releases)
200 if err != nil {
201 fmt.Println("Error upserting release:", err)
202 continue
203 }
204 }
205 return projectsList
206 }
207
208 projects := fetch()
209
210 for {
211 select {
212 case <-ticker.C:
213 projects = fetch()
214 case <-*manualRefresh:
215 ticker.Reset(time.Second * 3600)
216 projects = fetch()
217 case <-*req:
218 projectsCopy := make([]Project, len(projects))
219 copy(projectsCopy, projects)
220 *res <- projectsCopy
221 }
222 }
223}
224
225// GetProject returns a project from the database
226func GetProject(dbConn *sql.DB, proj Project) (Project, error) {
227 projectDB, err := db.GetProject(dbConn, proj.ID)
228 if err != nil && errors.Is(err, sql.ErrNoRows) {
229 return proj, nil
230 } else if err != nil {
231 return proj, err
232 }
233 p := Project{
234 ID: proj.ID,
235 URL: proj.URL,
236 Name: proj.Name,
237 Forge: proj.Forge,
238 Running: projectDB["version"],
239 }
240 return p, err
241}
242
243// GetProjectWithReleases returns a single project from the database along with its releases
244func GetProjectWithReleases(dbConn *sql.DB, mu *sync.Mutex, proj Project) (Project, error) {
245 project, err := GetProject(dbConn, proj)
246 if err != nil {
247 return Project{}, err
248 }
249
250 return GetReleases(dbConn, mu, project)
251}
252
253// GetProjects returns a list of all projects from the database
254func GetProjects(dbConn *sql.DB) ([]Project, error) {
255 projectsDB, err := db.GetProjects(dbConn)
256 if err != nil {
257 return nil, err
258 }
259
260 projects := make([]Project, len(projectsDB))
261 for i, p := range projectsDB {
262 projects[i] = Project{
263 ID: p["id"],
264 URL: p["url"],
265 Name: p["name"],
266 Forge: p["forge"],
267 Running: p["version"],
268 }
269 }
270
271 return projects, nil
272}
273
274// GetProjectsWithReleases returns a list of all projects and all their releases
275// from the database
276func GetProjectsWithReleases(dbConn *sql.DB, mu *sync.Mutex) ([]Project, error) {
277 projects, err := GetProjects(dbConn)
278 if err != nil {
279 return nil, err
280 }
281
282 for i := range projects {
283 projects[i], err = GetReleases(dbConn, mu, projects[i])
284 if err != nil {
285 return nil, err
286 }
287 projects[i].Releases = SortReleases(projects[i].Releases)
288 }
289
290 return projects, nil
291}