GitRoot

craft your forge, build your project, grow your community freely
  1// SPDX-FileCopyrightText: 2025 Romain Maneschi <romain@gitroot.dev>
  2//
  3// SPDX-License-Identifier: EUPL-1.2
  4
  5package main
  6
  7// compile cmd
  8// tinygo build -o grafter-0.0.1.wasm -scheduler=none --no-debug -target=wasi ./
  9
 10import (
 11	"bytes"
 12	_ "embed"
 13	"fmt"
 14	"io"
 15	"io/fs"
 16	"os"
 17	"path/filepath"
 18	"regexp"
 19	"strings"
 20
 21	"github.com/tidwall/gjson"
 22	gitroot "gitroot.dev/libs/golang/plugin"
 23	"gitroot.dev/libs/golang/plugin/model"
 24)
 25
 26type Plugin struct {
 27	server   model.Server
 28	conf     pluginConf
 29	newGraft *graft
 30}
 31
 32type pluginConf struct {
 33	defaultTargetBranch string
 34}
 35
 36const (
 37	statusNew    = ""
 38	statusDraft  = "draft"
 39	statusReview = "review"
 40	statusMerge  = "merge"
 41)
 42
 43type graft struct {
 44	status       string
 45	filename     string
 46	branch       string
 47	commitsLines []string
 48	pushContent  []string
 49	content      string
 50	currentHash  string
 51}
 52
 53var defaultRun = []model.PluginRun{{
 54	Path:   "**/*",
 55	Branch: []string{"*"},
 56	When:   model.PluginRunWhenAll,
 57	Write: model.PluginWrite{
 58		Git: []model.PluginWriteRight{{
 59			Path: "**/*",
 60			Can:  model.PluginWriteRightCanAll,
 61		}},
 62		Web: []model.PluginWriteRight{},
 63	},
 64	Configuration: map[string]any{"defaultTargetBranch": "main"},
 65}}
 66
 67var defaultGraft = `---
 68target: %s
 69status: draft
 70---
 71
 72# Want to merge %s
 73
 74Please add description of your work here.
 75
 76When your changes are ready to be reviewed, change the status from 'status: draft' to 'status: review'.
 77`
 78
 79var defaultReviewMessage = `
 80---
 81
 82Please review changes with:
 83
 84` + strings.Repeat("`", 3) + `shell
 85git fetch origin %s
 86git checkout %s
 87git diff origin/HEAD..HEAD
 88` + strings.Repeat("`", 3) + `
 89
 90Make your changes about the code inside the code directly (remarks, questions can be made in the form of comments). General remarks on the why can be made here. When done commit and push directly on this branch. Graft plugin will make the diff and display it here.
 91
 92When then changes are ready to be merged, change the status from 'status: review' to 'status: merge'.
 93`
 94
 95var targetRegexp = regexp.MustCompile(`- target:\s(.*)`)
 96
 97func (p *Plugin) Init(repoName string, confHasChanged bool, serializedConf string) error {
 98	defaultTargetBranch := gjson.Parse(serializedConf).Get("defaultTargetBranch").String()
 99	if defaultTargetBranch == "" {
100		defaultTargetBranch = "main"
101	}
102	p.conf = pluginConf{
103		defaultTargetBranch: defaultTargetBranch,
104	}
105	p.newGraft = nil
106	return nil
107}
108
109func (p *Plugin) StartCommit(commit model.Commit) error {
110	//does nothing if user push on main
111	if commit.Branch == p.conf.defaultTargetBranch {
112		return nil
113	}
114	commitLine := fmt.Sprintf("### %s (%s)\n", commit.Message, commit.Hash)
115	if p.newGraft == nil {
116		graftFilepath := fmt.Sprintf("grafts/%s.md", commit.Branch)
117		file, err := p.server.Worktree().Open(graftFilepath)
118		if err != nil && !os.IsNotExist(err) {
119			return err
120		}
121		if file == nil {
122			p.newGraft = &graft{
123				status:       statusNew,
124				filename:     graftFilepath,
125				branch:       commit.Branch,
126				commitsLines: []string{commitLine},
127				content:      fmt.Sprintf(defaultGraft, p.conf.defaultTargetBranch, commit.Branch),
128			}
129		} else {
130			filecontent, err := io.ReadAll(file)
131			if err != nil {
132				return err
133			}
134			metas, needMeta := p.parseMetas(filecontent)
135			if needMeta {
136				if bytes.HasPrefix(filecontent, []byte("---\n")) {
137					filecontent = bytes.TrimPrefix(filecontent, []byte("---\n"))
138					filecontent = filecontent[bytes.Index(filecontent, []byte("---\n"))+4:]
139				}
140				filecontent = append(p.serializeMetas(metas), bytes.TrimPrefix(filecontent, []byte("\n"))...)
141			}
142			p.newGraft = &graft{
143				status:       metas["status"],
144				filename:     graftFilepath,
145				branch:       commit.Branch,
146				commitsLines: []string{commitLine},
147				content:      string(filecontent),
148			}
149
150			formattedReviewMessage := fmt.Sprintf(defaultReviewMessage, commit.Branch, commit.Branch)
151			if metas["status"] == statusReview && !strings.Contains(p.newGraft.content, formattedReviewMessage) {
152				p.newGraft.content = fmt.Sprintf("%s%s", p.newGraft.content, formattedReviewMessage)
153			}
154		}
155	} else {
156		p.newGraft.commitsLines = append(p.newGraft.commitsLines, commitLine)
157	}
158	p.newGraft.pushContent = []string{}
159	p.newGraft.currentHash = commit.Hash
160	return nil
161}
162
163func (p *Plugin) serializeMetas(metas map[string]string) []byte {
164	m := strings.Builder{}
165	m.WriteString("---\n")
166	if target, ok := metas["target"]; ok {
167		m.WriteString(fmt.Sprintf("target: %s\n", target))
168	}
169	if status, ok := metas["status"]; ok {
170		m.WriteString(fmt.Sprintf("status: %s\n", status))
171	}
172	if reviewers, ok := metas["reviewers"]; ok {
173		m.WriteString(fmt.Sprintf("reviewers: %s\n", reviewers))
174	}
175	m.WriteString("---\n\n")
176	return []byte(m.String())
177}
178
179func (p *Plugin) parseMetas(filecontent []byte) (map[string]string, bool) {
180	metas := map[string]string{}
181	fileSplited := strings.SplitN(string(filecontent), "---", 3)
182	needMeta := false
183	if len(fileSplited) > 2 {
184		metasStr := strings.Split(fileSplited[1], "\n")
185		for _, m := range metasStr {
186			keyVal := strings.Split(m, ":")
187			if len(keyVal) > 1 {
188				metas[strings.TrimSpace(keyVal[0])] = strings.TrimSpace(keyVal[1])
189			}
190		}
191	}
192	if _, ok := metas["status"]; !ok {
193		needMeta = true
194		metas["status"] = statusDraft
195	}
196	if _, ok := metas["target"]; !ok {
197		needMeta = true
198		metas["target"] = p.conf.defaultTargetBranch
199	}
200	if _, ok := metas["reviewers"]; !ok && metas["status"] == statusReview {
201		needMeta = true
202		metas["reviewers"] = "[]"
203	}
204	return metas, needMeta
205}
206
207func (p *Plugin) AddFile(file model.File) error {
208	//does nothing if user push on main
209	if p.newGraft == nil {
210		return nil
211	}
212	if file.Path != p.newGraft.filename {
213		from, _ := filepath.Abs(file.Path)
214		relPath, _ := filepath.Rel("/grafts/", from)
215		p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- ++ [%s](%s)", file.Path, filepath.Clean(relPath)))
216	}
217	return nil
218}
219
220func (p *Plugin) ModFile(file model.File) error {
221	//does nothing if user push on main
222	if p.newGraft == nil {
223		return nil
224	}
225
226	if file.Path != p.newGraft.filename {
227		from, _ := filepath.Abs(file.Path)
228		relPath, _ := filepath.Rel("/grafts/", from)
229		if file.OldPath != file.Path {
230			p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- %s -> [%s](%s)", file.OldPath, file.Path, filepath.Clean(relPath)))
231		}
232		p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- +- [%s](%s)", file.Path, filepath.Clean(relPath)))
233		if p.newGraft.status == statusReview {
234			if diff, err := p.server.DiffWithParent(p.newGraft.currentHash, file.OldPath, file.Path); err == nil {
235				p.newGraft.pushContent = append(p.newGraft.pushContent, diff)
236			} else {
237				p.server.LogError("can't get diff", err)
238			}
239		}
240	}
241	return nil
242}
243
244func (p *Plugin) DelFile(file model.File) error {
245	//does nothing if user push on main
246	if p.newGraft == nil {
247		return nil
248	}
249	if file.Path != p.newGraft.filename {
250		from, _ := filepath.Abs(file.Path)
251		relPath, _ := filepath.Rel("/grafts/", from)
252		p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- -- [%s](%s)", file.Path, filepath.Clean(relPath)))
253	}
254	return nil
255}
256
257func (p *Plugin) EndCommit(commit model.Commit) error {
258	//does nothing if user push on main
259	if p.newGraft == nil {
260		return nil
261	}
262	if len(p.newGraft.pushContent) > 0 {
263		p.newGraft.commitsLines[len(p.newGraft.commitsLines)-1] = fmt.Sprintf("%s\n%s\n", p.newGraft.commitsLines[len(p.newGraft.commitsLines)-1], strings.Join(p.newGraft.pushContent, "\n"))
264	} else {
265		// a commit which touch others files or only graft file is ignored
266		p.newGraft.commitsLines = p.newGraft.commitsLines[:len(p.newGraft.commitsLines)-1]
267	}
268	return nil
269}
270
271func (p *Plugin) Finish() error {
272	// does nothing if user push on main
273	if p.newGraft == nil {
274		return nil
275	}
276	content := p.newGraft.content
277	if len(p.newGraft.commitsLines) > 0 {
278		commit := "commit"
279		if len(p.newGraft.commitsLines) > 1 {
280			commit = "commits"
281		}
282		content = fmt.Sprintf("%s\n---\n\n## Push %d %s\n\n%s", p.newGraft.content, len(p.newGraft.commitsLines), commit, strings.Join(p.newGraft.commitsLines, "\n"))
283	}
284	p.server.ModifyContent(p.newGraft.filename, content)
285	message := "graft updated"
286	if p.newGraft.status == statusNew {
287		message = "graft created"
288	}
289	p.server.CommitAllIfNeeded(message)
290	if p.newGraft.status == statusMerge {
291		all := targetRegexp.FindStringSubmatch(p.newGraft.content)
292		if len(all) > 1 {
293			p.server.Merge(all[1], p.newGraft.branch)
294		} else {
295			p.server.Merge(p.conf.defaultTargetBranch, p.newGraft.branch)
296		}
297	}
298	return nil
299}
300
301func (p *Plugin) Report(report model.Report) error {
302	if report.FromBranch == p.conf.defaultTargetBranch {
303		p.server.Log(fmt.Sprintf("grafter don't report because %s is defaultTargetBranch, original msg: %s", report.FromBranch, strings.Join(report.Content, "\n")))
304		return nil
305	}
306
307	graftFilepath := fmt.Sprintf("grafts/%s.md", report.FromBranch)
308	filecontent, err := fs.ReadFile(p.server.Worktree(), graftFilepath)
309	if err != nil {
310		return err
311	}
312	// TODO should be append when api permit to append
313	content := fmt.Sprintf("%s\n---\n\n## Report **%s** from %s\n\n%s\n", filecontent, report.Level, report.FromPlugin, strings.Join(report.Content, "\n"))
314	p.server.ModifyContent(graftFilepath, content)
315	p.server.CommitAllIfNeeded("graft updated with report")
316	return nil
317}
318
319func Build(server model.Server) model.Plugin {
320	p := &Plugin{
321		server: server,
322	}
323	server.PluginOption(model.WithPluginReporter(p.Report))
324	return p
325}
326
327//go:wasmexport install
328func main() {
329	gitroot.Register(defaultRun, Build)
330}