/ project / project.go
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  }