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	Func:   []model.PluginFunc{},
 58	Write: model.PluginWrite{
 59		Git: []model.PluginWriteRight{{
 60			Path: "**/*",
 61			Can:  model.PluginWriteRightCanAll,
 62		}},
 63		Web:      []model.PluginWriteRight{},
 64		Exec:     []model.PluginExecRight{},
 65		CallFunc: []model.PluginCallFuncRight{},
 66	},
 67	Configuration: map[string]any{"defaultTargetBranch": "main"},
 68}}
 69
 70var defaultGraft = `---
 71target: %s
 72status: draft
 73---
 74
 75# Want to merge %s
 76
 77Please add description of your work here.
 78
 79When your changes are ready to be reviewed, change the status from 'status: draft' to 'status: review'.
 80`
 81
 82var defaultReviewMessage = `
 83---
 84
 85Please review changes with:
 86
 87` + strings.Repeat("`", 3) + `shell
 88git fetch origin %s
 89git checkout %s
 90git diff origin/HEAD..HEAD
 91` + strings.Repeat("`", 3) + `
 92
 93Make 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.
 94
 95When then changes are ready to be merged, change the status from 'status: review' to 'status: merge'.
 96`
 97
 98var targetRegexp = regexp.MustCompile(`- target:\s(.*)`)
 99
100func (p *Plugin) Init(repoName string, confHasChanged bool, serializedConf string) error {
101	defaultTargetBranch := gjson.Parse(serializedConf).Get("defaultTargetBranch").String()
102	if defaultTargetBranch == "" {
103		defaultTargetBranch = "main"
104	}
105	p.conf = pluginConf{
106		defaultTargetBranch: defaultTargetBranch,
107	}
108	p.newGraft = nil
109	return nil
110}
111
112func (p *Plugin) StartCommit(commit model.Commit) error {
113	//does nothing if user push on main
114	if commit.Branch == p.conf.defaultTargetBranch {
115		return nil
116	}
117	commitLine := fmt.Sprintf("### %s (%s)\n", commit.Message, commit.Hash)
118	if p.newGraft == nil {
119		graftFilepath := fmt.Sprintf("grafts/%s.md", commit.Branch)
120		file, err := p.server.Worktree().Open(graftFilepath)
121		if err != nil && !os.IsNotExist(err) {
122			return err
123		}
124		if file == nil {
125			p.newGraft = &graft{
126				status:       statusNew,
127				filename:     graftFilepath,
128				branch:       commit.Branch,
129				commitsLines: []string{commitLine},
130				content:      fmt.Sprintf(defaultGraft, p.conf.defaultTargetBranch, commit.Branch),
131			}
132		} else {
133			filecontent, err := io.ReadAll(file)
134			if err != nil {
135				return err
136			}
137			metas, needMeta := p.parseMetas(filecontent)
138			if needMeta {
139				if bytes.HasPrefix(filecontent, []byte("---\n")) {
140					filecontent = bytes.TrimPrefix(filecontent, []byte("---\n"))
141					filecontent = filecontent[bytes.Index(filecontent, []byte("---\n"))+4:]
142				}
143				filecontent = append(p.serializeMetas(metas), bytes.TrimPrefix(filecontent, []byte("\n"))...)
144			}
145			p.newGraft = &graft{
146				status:       metas["status"],
147				filename:     graftFilepath,
148				branch:       commit.Branch,
149				commitsLines: []string{commitLine},
150				content:      string(filecontent),
151			}
152
153			formattedReviewMessage := fmt.Sprintf(defaultReviewMessage, commit.Branch, commit.Branch)
154			if metas["status"] == statusReview && !strings.Contains(p.newGraft.content, formattedReviewMessage) {
155				p.newGraft.content = fmt.Sprintf("%s%s", p.newGraft.content, formattedReviewMessage)
156			}
157		}
158	} else {
159		p.newGraft.commitsLines = append(p.newGraft.commitsLines, commitLine)
160	}
161	p.newGraft.pushContent = []string{}
162	p.newGraft.currentHash = commit.Hash
163	return nil
164}
165
166func (p *Plugin) serializeMetas(metas map[string]string) []byte {
167	m := strings.Builder{}
168	m.WriteString("---\n")
169	if target, ok := metas["target"]; ok {
170		m.WriteString(fmt.Sprintf("target: %s\n", target))
171	}
172	if status, ok := metas["status"]; ok {
173		m.WriteString(fmt.Sprintf("status: %s\n", status))
174	}
175	if reviewers, ok := metas["reviewers"]; ok {
176		m.WriteString(fmt.Sprintf("reviewers: %s\n", reviewers))
177	}
178	m.WriteString("---\n\n")
179	return []byte(m.String())
180}
181
182func (p *Plugin) parseMetas(filecontent []byte) (map[string]string, bool) {
183	metas := map[string]string{}
184	fileSplited := strings.SplitN(string(filecontent), "---", 3)
185	needMeta := false
186	if len(fileSplited) > 2 {
187		metasStr := strings.Split(fileSplited[1], "\n")
188		for _, m := range metasStr {
189			keyVal := strings.Split(m, ":")
190			if len(keyVal) > 1 {
191				metas[strings.TrimSpace(keyVal[0])] = strings.TrimSpace(keyVal[1])
192			}
193		}
194	}
195	if _, ok := metas["status"]; !ok {
196		needMeta = true
197		metas["status"] = statusDraft
198	}
199	if _, ok := metas["target"]; !ok {
200		needMeta = true
201		metas["target"] = p.conf.defaultTargetBranch
202	}
203	if _, ok := metas["reviewers"]; !ok && metas["status"] == statusReview {
204		needMeta = true
205		metas["reviewers"] = "[]"
206	}
207	return metas, needMeta
208}
209
210func (p *Plugin) AddFile(file model.File) error {
211	//does nothing if user push on main
212	if p.newGraft == nil {
213		return nil
214	}
215	if file.Path != p.newGraft.filename {
216		from, _ := filepath.Abs(file.Path)
217		relPath, _ := filepath.Rel("/grafts/", from)
218		p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- ++ [%s](%s)", file.Path, filepath.Clean(relPath)))
219	}
220	return nil
221}
222
223func (p *Plugin) ModFile(file model.File) error {
224	//does nothing if user push on main
225	if p.newGraft == nil {
226		return nil
227	}
228
229	if file.Path != p.newGraft.filename {
230		from, _ := filepath.Abs(file.Path)
231		relPath, _ := filepath.Rel("/grafts/", from)
232		if file.OldPath != file.Path {
233			p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- %s -> [%s](%s)", file.OldPath, file.Path, filepath.Clean(relPath)))
234		}
235		p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- +- [%s](%s)", file.Path, filepath.Clean(relPath)))
236		if p.newGraft.status == statusReview {
237			if diff, err := p.server.DiffWithParent(p.newGraft.currentHash, file.OldPath, file.Path); err == nil {
238				p.newGraft.pushContent = append(p.newGraft.pushContent, diff)
239			} else {
240				p.server.LogError("can't get diff", err)
241			}
242		}
243	}
244	return nil
245}
246
247func (p *Plugin) DelFile(file model.File) error {
248	//does nothing if user push on main
249	if p.newGraft == nil {
250		return nil
251	}
252	if file.Path != p.newGraft.filename {
253		from, _ := filepath.Abs(file.Path)
254		relPath, _ := filepath.Rel("/grafts/", from)
255		p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- -- [%s](%s)", file.Path, filepath.Clean(relPath)))
256	}
257	return nil
258}
259
260func (p *Plugin) EndCommit(commit model.Commit) error {
261	//does nothing if user push on main
262	if p.newGraft == nil {
263		return nil
264	}
265	if len(p.newGraft.pushContent) > 0 {
266		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"))
267	} else {
268		// a commit which touch others files or only graft file is ignored
269		p.newGraft.commitsLines = p.newGraft.commitsLines[:len(p.newGraft.commitsLines)-1]
270	}
271	return nil
272}
273
274func (p *Plugin) Finish() error {
275	// does nothing if user push on main
276	if p.newGraft == nil {
277		return nil
278	}
279	content := p.newGraft.content
280	if len(p.newGraft.commitsLines) > 0 {
281		commit := "commit"
282		if len(p.newGraft.commitsLines) > 1 {
283			commit = "commits"
284		}
285		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"))
286	}
287	p.server.ModifyContent(p.newGraft.filename, content)
288	message := "graft updated"
289	if p.newGraft.status == statusNew {
290		message = "graft created"
291	}
292	p.server.CommitAllIfNeeded(message)
293	if p.newGraft.status == statusMerge {
294		all := targetRegexp.FindStringSubmatch(p.newGraft.content)
295		if len(all) > 1 {
296			p.server.Merge(all[1], p.newGraft.branch)
297		} else {
298			p.server.Merge(p.conf.defaultTargetBranch, p.newGraft.branch)
299		}
300	}
301	return nil
302}
303
304func (p *Plugin) Report(report model.Report) error {
305	if report.FromBranch == p.conf.defaultTargetBranch {
306		p.server.Log(fmt.Sprintf("grafter don't report because %s is defaultTargetBranch, original msg: %s", report.FromBranch, strings.Join(report.Content, "\n")))
307		return nil
308	}
309
310	graftFilepath := fmt.Sprintf("grafts/%s.md", report.FromBranch)
311	filecontent, err := fs.ReadFile(p.server.Worktree(), graftFilepath)
312	if err != nil {
313		return err
314	}
315	// TODO should be append when api permit to append
316	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"))
317	p.server.ModifyContent(graftFilepath, content)
318	p.server.CommitAllIfNeeded("graft updated with report")
319	return nil
320}
321
322func Build(server model.Server) model.Plugin {
323	p := &Plugin{
324		server: server,
325	}
326	server.PluginOption(model.WithPluginReporter(p.Report))
327	return p
328}
329
330//go:wasmexport install
331func main() {
332	gitroot.Register(defaultRun, Build)
333}