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}