project.go

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