feat: Suggest pull request title/description via AI (OD-2602)

This commit is contained in:
Robin Shen 2025-11-14 10:59:05 +08:00
parent a62db8265f
commit 706b2dbdba
18 changed files with 443 additions and 132 deletions

View File

@ -566,7 +566,7 @@ public class McpHelperResource {
createPullRequestInputSchema.put("Type", "object");
createPullRequestInputSchema.put("Properties", createPullRequestProperties);
createPullRequestInputSchema.put("Required", List.of("sourceBranch", "title"));
createPullRequestInputSchema.put("Required", List.of("sourceBranch"));
inputSchemas.put("createPullRequest", createPullRequestInputSchema);
@ -1432,13 +1432,6 @@ public class McpHelperResource {
if (baseCommitId == null)
throw new NotAcceptableException("No common base for source and target branches");
var title = (String) data.remove("title");
if (title == null)
throw new NotAcceptableException("Title is required");
request.cleanTitle();
request.setDescription((String) data.remove("description"));
request.setTarget(target);
request.setSource(source);
request.setSubmitter(SecurityUtils.getUser());
@ -1461,7 +1454,21 @@ public class McpHelperResource {
update.setTargetHeadCommitHash(request.getTarget().getObjectName());
request.getUpdates().add(update);
request.generateTitleAndDescriptionIfEmpty();
var title = (String) data.remove("title");
if (title == null)
title = request.generateTitleFromCommits();
else
title = request.cleanTitle(title);
if (title == null)
title = request.generateTitleFromBranch();
request.setTitle(title);
var description = (String) data.remove("description");
if (description == null)
description = request.generateDescriptionFromCommits();
request.setDescription(description);
pullRequestService.checkReviews(request, false);

View File

@ -49,7 +49,6 @@ import java.util.Stack;
import java.util.UUID;
import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
import javax.persistence.AttributeOverride;
import javax.persistence.AttributeOverrides;
import javax.persistence.CascadeType;
@ -68,6 +67,7 @@ import org.apache.commons.lang3.StringUtils;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.revwalk.RevCommit;
import org.hibernate.annotations.DynamicUpdate;
import org.jspecify.annotations.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
@ -76,8 +76,6 @@ import com.google.common.collect.Lists;
import io.onedev.server.OneDev;
import io.onedev.server.attachment.AttachmentStorageSupport;
import io.onedev.server.service.PullRequestService;
import io.onedev.server.service.UserService;
import io.onedev.server.entityreference.EntityReference;
import io.onedev.server.entityreference.PullRequestReference;
import io.onedev.server.git.GitUtils;
@ -96,6 +94,9 @@ import io.onedev.server.model.support.pullrequest.MergeStrategy;
import io.onedev.server.rest.annotation.Api;
import io.onedev.server.search.entity.SortField;
import io.onedev.server.security.SecurityUtils;
import io.onedev.server.service.PullRequestService;
import io.onedev.server.service.UserService;
import io.onedev.server.util.BranchSemantic;
import io.onedev.server.util.ComponentContext;
import io.onedev.server.util.ProjectAndBranch;
import io.onedev.server.web.asset.emoji.Emojis;
@ -1410,56 +1411,85 @@ public class PullRequest extends ProjectBelonging
}
// Remove issue number suffix if there is any
public void cleanTitle() {
public String cleanTitle(String title) {
var cleanedTitle = title.replaceFirst("\\s*\\([a-zA-Z]+-\\d+\\)$", "");
cleanedTitle = cleanedTitle.replaceFirst("\\s*\\(#\\d+\\)$", "").trim();
if (cleanedTitle.length() != 0)
title = cleanedTitle;
return cleanedTitle;
else
return title;
}
public void generateTitleAndDescriptionIfEmpty() {
if (title == null) {
var commits = getLatestUpdate().getCommits();
if (commits.size() == 1) {
title = commits.get(0).getShortMessage();
cleanTitle();
} else {
title = getSource().getBranch().toLowerCase();
boolean wip = false;
if (title.startsWith("wip-") || title.startsWith("wip_") || title.startsWith("wip/")) {
wip = true;
title = title.substring(4);
}
var commitTypes = CONVENTIONAL_COMMIT_TYPES;
var branchProtection = getProject().getBranchProtection(getTargetBranch(), getSubmitter());
if (branchProtection.isEnforceConventionalCommits() && !branchProtection.getCommitTypes().isEmpty()) {
commitTypes = branchProtection.getCommitTypes();
}
boolean found = false;
for (var commitType: commitTypes) {
if (title.startsWith(commitType + "-") || title.startsWith(commitType + "_") || title.startsWith(commitType + "/")) {
title = commitType + ": " + StringUtils.capitalize(title.substring(commitType.length() + 1).replace('-', ' ').replace('_', ' ').replace('/', ' '));
found = true;
break;
}
}
if (!found) {
title = StringUtils.capitalize(title.replace('-', ' ').replace('_', ' ').replace('/', ' '));
}
if (wip)
title = "[WIP] " + title;
public BranchSemantic getSourceBranchSemantic() {
var work = getSourceBranch();
boolean workInProgress = false;
if (work.toLowerCase().startsWith("wip-")
|| work.toLowerCase().startsWith("wip_")
|| work.toLowerCase().startsWith("wip/")) {
workInProgress = true;
work = work.substring(4);
}
var commitTypes = CONVENTIONAL_COMMIT_TYPES;
var branchProtection = getProject().getBranchProtection(getTargetBranch(), getSubmitter());
if (branchProtection.isEnforceConventionalCommits() && !branchProtection.getCommitTypes().isEmpty()) {
commitTypes = branchProtection.getCommitTypes();
}
String workType = null;
for (var commitType: commitTypes) {
if (work.toLowerCase().startsWith(commitType + "-")
|| work.toLowerCase().startsWith(commitType + "_")
|| work.toLowerCase().startsWith(commitType + "/")) {
workType = commitType;
work = work.substring(commitType.length() + 1);
break;
}
}
if (description == null) {
var commits = getLatestUpdate().getCommits();
if (commits.size() == 1) {
var commit = commits.get(0);
var shortMessage = commits.get(0).getShortMessage();
description = commit.getFullMessage().substring(shortMessage.length()).trim();
if (description.length() == 0)
description = null;
}
}
return new BranchSemantic(workInProgress, workType, StringUtils.trimToNull(work));
}
public String getTitlePrefix(BranchSemantic sourceBranchSemantic) {
var prefixBuilder = new StringBuilder();
if (sourceBranchSemantic.isWorkInProgress()) {
prefixBuilder.append("[WIP] ");
}
if (sourceBranchSemantic.getWorkType() != null) {
prefixBuilder.append(sourceBranchSemantic.getWorkType()).append(": ");
}
return prefixBuilder.toString();
}
public String generateTitleFromBranch() {
var sourceBranchSemantic = getSourceBranchSemantic();
var title = getTitlePrefix(sourceBranchSemantic);
if (sourceBranchSemantic.getWorkDescription() != null) {
title += StringUtils.capitalize(sourceBranchSemantic.getWorkDescription().toLowerCase().replace('-', ' ').replace('_', ' ').replace('/', ' '));
}
return title.trim();
}
@Nullable
public String generateTitleFromCommits() {
var commits = getLatestUpdate().getCommits();
if (commits.size() == 1) {
return cleanTitle(commits.get(0).getShortMessage());
} else {
return null;
}
}
@Nullable
public String generateDescriptionFromCommits() {
var commits = getLatestUpdate().getCommits();
if (commits.size() == 1) {
var commit = commits.get(0);
var shortMessage = commit.getShortMessage();
var description = commit.getFullMessage().substring(shortMessage.length()).trim();
if (description.length() == 0)
description = null;
return description;
} else {
return null;
}
}
}

View File

@ -0,0 +1,33 @@
package io.onedev.server.util;
import org.jspecify.annotations.Nullable;
public class BranchSemantic {
private final boolean workInProgress;
private final String workType;
private final String workDescription;
public BranchSemantic(boolean workInProgress, @Nullable String workType, @Nullable String workDescription) {
this.workInProgress = workInProgress;
this.workType = workType;
this.workDescription = workDescription;
}
public boolean isWorkInProgress() {
return workInProgress;
}
@Nullable
public String getWorkType() {
return workType;
}
@Nullable
public String getWorkDescription() {
return workDescription;
}
}

View File

@ -30,8 +30,6 @@ import static io.onedev.server.web.translation.Translation._T;
import static java.util.Collections.sort;
public class AgentQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final boolean forExecutor;
@ -188,8 +186,8 @@ public class AgentQueryBehavior extends ANTLRAssistBehavior {
@Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX);
return suggestion.getLabel().startsWith("~")
&& suggestion.getLabel().endsWith("~");
}
}

View File

@ -38,8 +38,6 @@ import io.onedev.server.web.behavior.inputassist.NaturalLanguageTranslator;
import io.onedev.server.web.util.SuggestionUtils;
public class BuildQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final IModel<Project> projectModel;
@ -286,8 +284,8 @@ public class BuildQueryBehavior extends ANTLRAssistBehavior {
@Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX);
return suggestion.getLabel().startsWith("~")
&& suggestion.getLabel().endsWith("~");
}
}

View File

@ -30,8 +30,6 @@ import static io.onedev.server.search.entity.codecomment.CodeCommentQueryLexer.*
import static io.onedev.server.web.translation.Translation._T;
public class CodeCommentQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final IModel<Project> projectModel;
@ -175,8 +173,8 @@ public class CodeCommentQueryBehavior extends ANTLRAssistBehavior {
@Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX);
return suggestion.getLabel().startsWith("~")
&& suggestion.getLabel().endsWith("~");
}
}

View File

@ -36,8 +36,6 @@ import io.onedev.server.xodus.CommitInfoService;
public class CommitQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final IModel<Project> projectModel;
private final boolean withCurrentUserCriteria;
@ -198,8 +196,8 @@ public class CommitQueryBehavior extends ANTLRAssistBehavior {
@Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX);
return suggestion.getLabel().startsWith("~")
&& suggestion.getLabel().endsWith("~");
}
}

View File

@ -109,8 +109,6 @@ import io.onedev.server.web.util.SuggestionUtils;
import io.onedev.server.web.util.WicketUtils;
public class IssueQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final IModel<Project> projectModel;
@ -439,7 +437,8 @@ public class IssueQueryBehavior extends ANTLRAssistBehavior {
@Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null && (suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX));
return suggestion.getLabel().startsWith("~")
&& suggestion.getLabel().endsWith("~");
}
@Override

View File

@ -33,8 +33,6 @@ import static io.onedev.server.web.translation.Translation._T;
public class PackQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final IModel<Project> projectModel;
private final String packType;
@ -222,8 +220,8 @@ public class PackQueryBehavior extends ANTLRAssistBehavior {
@Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX);
return suggestion.getLabel().startsWith("~")
&& suggestion.getLabel().endsWith("~");
}
}

View File

@ -37,8 +37,6 @@ import static io.onedev.server.web.translation.Translation._T;
public class ProjectQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final boolean childQuery;
public ProjectQueryBehavior(boolean childQuery) {
@ -225,8 +223,8 @@ public class ProjectQueryBehavior extends ANTLRAssistBehavior {
@Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX);
return suggestion.getLabel().startsWith("~")
&& suggestion.getLabel().endsWith("~");
}
}

View File

@ -56,8 +56,6 @@ import io.onedev.server.web.behavior.inputassist.NaturalLanguageTranslator;
import io.onedev.server.web.util.SuggestionUtils;
public class PullRequestQueryBehavior extends ANTLRAssistBehavior {
private static final String FUZZY_SUGGESTION_DESCRIPTION_PREFIX = "enclose with ~";
private final IModel<Project> projectModel;
@ -270,8 +268,8 @@ public class PullRequestQueryBehavior extends ANTLRAssistBehavior {
@Override
protected boolean isFuzzySuggestion(InputCompletion suggestion) {
return suggestion.getDescription() != null
&& suggestion.getDescription().startsWith(FUZZY_SUGGESTION_DESCRIPTION_PREFIX);
return suggestion.getLabel().startsWith("~")
&& suggestion.getLabel().endsWith("~");
}
@Override

View File

@ -12,31 +12,31 @@ import io.onedev.server.OneDev;
public class JavascriptTranslations {
static String get() {
try {
var map = new HashMap<String, String>();
map.put("loading", _T("Loading..."));
map.put("loading-emojis", _T("Loading emojis..."));
map.put("commented-code-outdated", _T("Commented code is outdated"));
map.put("suggest-changes", _T("Suggest changes"));
map.put("upload-should-be-less-than", _T("Upload should be less than {0} Mb"));
map.put("uploading-file", _T("Uploading file"));
map.put("unable-to-connect-to-server", _T("Unable to connect to server"));
map.put("programming-language", _T("Programming language"));
map.put("copy-to-clipboard", _T("Copy to clipboard"));
map.put("suggestion-outdated", _T("Suggestion is outdated either due to code change or pull request close"));
map.put("remove-from-batch", _T("Remove from batch"));
map.put("add-to-batch", _T("Add to batch to commit with other suggestions later"));
map.put("commit-suggestion", _T("Commit suggestion"));
map.put("issue-not-exist-or-access-denied", _T("Issue not exist or access denied"));
map.put("pull-request-not-exist-or-access-denied", _T("Pull request not exist or access denied"));
map.put("build-not-exist-or-access-denied", _T("Build not exist or access denied"));
map.put("commit-not-exist-or-access-denied", _T("Commit not exist or access denied"));
map.put("enter-description-here", _T("Enter description here"));
try {
var map = new HashMap<String, String>();
map.put("loading", _T("Loading..."));
map.put("loading-emojis", _T("Loading emojis..."));
map.put("commented-code-outdated", _T("Commented code is outdated"));
map.put("suggest-changes", _T("Suggest changes"));
map.put("upload-should-be-less-than", _T("Upload should be less than {0} Mb"));
map.put("uploading-file", _T("Uploading file"));
map.put("unable-to-connect-to-server", _T("Unable to connect to server"));
map.put("programming-language", _T("Programming language"));
map.put("copy-to-clipboard", _T("Copy to clipboard"));
map.put("suggestion-outdated", _T("Suggestion is outdated either due to code change or pull request close"));
map.put("remove-from-batch", _T("Remove from batch"));
map.put("add-to-batch", _T("Add to batch to commit with other suggestions later"));
map.put("commit-suggestion", _T("Commit suggestion"));
map.put("issue-not-exist-or-access-denied", _T("Issue not exist or access denied"));
map.put("pull-request-not-exist-or-access-denied", _T("Pull request not exist or access denied"));
map.put("build-not-exist-or-access-denied", _T("Build not exist or access denied"));
map.put("commit-not-exist-or-access-denied", _T("Commit not exist or access denied"));
map.put("enter-description-here", _T("Enter description here"));
return OneDev.getInstance(ObjectMapper.class).writeValueAsString(map);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
return OneDev.getInstance(ObjectMapper.class).writeValueAsString(map);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -17,9 +17,9 @@ public class SymbolContext implements Serializable {
private final List<String> linesAtStart;
public SymbolContext(String fileName, String symbolLine,
public SymbolContext(String blobPath, String symbolLine,
List<String> linesBeforeSymbolLine, List<String> linesAfterSymbolLine, List<String> linesAtStart) {
this.blobPath = fileName;
this.blobPath = blobPath;
this.symbolLine = symbolLine;
this.linesBeforeSymbolLine = linesBeforeSymbolLine;
this.linesAfterSymbolLine = linesAfterSymbolLine;

View File

@ -37,8 +37,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import io.onedev.commons.jsymbol.Symbol;
import io.onedev.commons.utils.ExplicitException;
import io.onedev.commons.utils.LinearRange;
import io.onedev.commons.utils.PlanarRange;
import io.onedev.server.exception.ExceptionUtils;
import io.onedev.server.git.Blob;
import io.onedev.server.git.BlobIdent;
import io.onedev.server.model.Project;
@ -59,7 +61,7 @@ import io.onedev.server.web.page.project.blob.render.BlobRenderer;
public abstract class SymbolTooltipPanel extends Panel {
private static final int QUERY_ENTRIES = 20;
private static final int QUERY_ENTRIES = 25;
private static final int BEFORE_CONTEXT_SIZE = 5;
@ -321,6 +323,7 @@ public abstract class SymbolTooltipPanel extends Panel {
mapperCopy.addMixIn(PlanarRange.class, IgnorePlanarRangeMixin.class);
mapperCopy.addMixIn(LinearRange.class, IgnoreLinearRangeMixin.class);
var jsonOfSymbolHits = mapperCopy.writeValueAsString(symbolHits);
var symbolContext = getSymbolContext(symbolPosition, BEFORE_CONTEXT_SIZE,
AFTER_CONTEXT_SIZE, AT_START_CONTEXT_SIZE);
var jsonOfSymbolContext = mapperCopy.writeValueAsString(symbolContext);
@ -349,9 +352,14 @@ public abstract class SymbolTooltipPanel extends Panel {
if (index < 0 || index >= symbolHits.size())
Session.get().warn("Unable to find most likely definition");
} catch (Exception e) {
var explicitException = ExceptionUtils.find(e, ExplicitException.class);
if (explicitException != null) {
Session.get().error(explicitException.getMessage());
} else {
logger.error("Error inferring most likely symbol definition", e);
Session.get().error("Error inferring most likely symbol definition, check server log for details");
}
index = -1;
logger.error("Error inferring most likely symbol definition", e);
Session.get().error("Error inferring most likely symbol definition, check server log for details");
}
var script = String.format("onedev.server.symboltooltip.doneInfer('%s', %d);",
getMarkupId() + "-symbol-tooltip", index);

View File

@ -67,13 +67,13 @@
<div class="card can-send mb-5">
<div class="card-body">
<form wicket:id="form" class="leave-confirm">
<div class="form-group">
<div class="form-group pull-request-title">
<label><wicket:t>Title</wicket:t> <span class="text-danger">*</span></label>
<input wicket:id="title" type="text" class="form-control">
<div wicket:id="titleFeedback"></div>
<div class="text-muted form-text"><wicket:t>Prefix the title with <code>WIP</code> or <code>[WIP]</code> to mark the pull request as work in progress</wicket:t></div>
</div>
<div class="form-group">
<div class="form-group pull-request-description">
<label><wicket:t>Description</wicket:t></label>
<div wicket:id="description"></div>
<div wicket:id="descriptionFeedback"></div>

View File

@ -1,7 +1,11 @@
package io.onedev.server.web.page.project.pullrequests.create;
import static io.onedev.server.model.PullRequest.MAX_DESCRIPTION_LEN;
import static io.onedev.server.model.PullRequest.MAX_TITLE_LEN;
import static io.onedev.server.search.commit.Revision.Type.COMMIT;
import static io.onedev.server.web.translation.Translation._T;
import static org.apache.wicket.ajax.attributes.CallbackParameter.explicit;
import static org.unbescape.javascript.JavaScriptEscape.escapeJavaScript;
import java.text.MessageFormat;
import java.util.ArrayList;
@ -16,10 +20,11 @@ import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
import javax.inject.Inject;
import org.apache.wicket.Component;
import org.apache.wicket.RestartResponseAtInterceptPageException;
import org.apache.wicket.Session;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.attributes.AjaxRequestAttributes;
import org.apache.wicket.ajax.form.OnChangeAjaxBehavior;
@ -27,8 +32,9 @@ import org.apache.wicket.ajax.markup.html.AjaxLink;
import org.apache.wicket.behavior.AttributeAppender;
import org.apache.wicket.extensions.ajax.markup.html.AjaxLazyLoadPanel;
import org.apache.wicket.feedback.FencedFeedbackPanel;
import org.apache.wicket.markup.head.CssHeaderItem;
import org.apache.wicket.markup.head.IHeaderResponse;
import org.apache.wicket.markup.head.JavaScriptHeaderItem;
import org.apache.wicket.markup.head.OnLoadHeaderItem;
import org.apache.wicket.markup.html.WebMarkupContainer;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.form.Button;
@ -47,9 +53,17 @@ import org.apache.wicket.request.cycle.RequestCycle;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.revwalk.RevCommit;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import io.onedev.commons.utils.ExplicitException;
import io.onedev.commons.utils.PlanarRange;
import io.onedev.server.OneDev;
import io.onedev.server.attachment.AttachmentSupport;
@ -58,11 +72,7 @@ import io.onedev.server.codequality.CodeProblem;
import io.onedev.server.codequality.CodeProblemContribution;
import io.onedev.server.codequality.CoverageStatus;
import io.onedev.server.codequality.LineCoverageContribution;
import io.onedev.server.service.CodeCommentService;
import io.onedev.server.service.CodeCommentReplyService;
import io.onedev.server.service.CodeCommentStatusChangeService;
import io.onedev.server.service.LabelSpecService;
import io.onedev.server.service.PullRequestService;
import io.onedev.server.exception.ExceptionUtils;
import io.onedev.server.git.GitUtils;
import io.onedev.server.git.service.GitService;
import io.onedev.server.git.service.RefFacade;
@ -87,10 +97,17 @@ import io.onedev.server.search.commit.CommitQuery;
import io.onedev.server.search.commit.Revision;
import io.onedev.server.search.commit.RevisionCriteria;
import io.onedev.server.security.SecurityUtils;
import io.onedev.server.service.CodeCommentReplyService;
import io.onedev.server.service.CodeCommentService;
import io.onedev.server.service.CodeCommentStatusChangeService;
import io.onedev.server.service.LabelSpecService;
import io.onedev.server.service.PullRequestService;
import io.onedev.server.service.SettingService;
import io.onedev.server.util.Pair;
import io.onedev.server.util.ProjectAndBranch;
import io.onedev.server.util.diff.WhitespaceOption;
import io.onedev.server.web.ajaxlistener.DisableGlobalAjaxIndicatorListener;
import io.onedev.server.web.behavior.AbstractPostAjaxBehavior;
import io.onedev.server.web.behavior.ReferenceInputBehavior;
import io.onedev.server.web.component.branch.BranchLink;
import io.onedev.server.web.component.branch.picker.AffinalBranchPicker;
@ -119,10 +136,18 @@ import io.onedev.server.web.util.editbean.LabelsBean;
public class NewPullRequestPage extends ProjectPage implements RevisionAnnotationSupport {
private static final Logger logger = LoggerFactory.getLogger(NewPullRequestPage.class);
private static final String TABS_ID = "tabs";
private static final String TAB_PANEL_ID = "tabPanel";
@Inject
private SettingService settingService;
@Inject
private ObjectMapper objectMapper;
private ProjectAndBranch target;
private ProjectAndBranch source;
@ -262,7 +287,11 @@ public class NewPullRequestPage extends ProjectPage implements RevisionAnnotatio
update.setHeadCommitHash(source.getObjectName());
update.setTargetHeadCommitHash(request.getTarget().getObjectName());
request.generateTitleAndDescriptionIfEmpty();
var title = request.generateTitleFromCommits();
if (title == null && settingService.getAISetting().getLiteModelSetting() == null)
title = request.generateTitleFromBranch();
request.setTitle(title);
request.setDescription(request.generateDescriptionFromCommits());
getPullRequestService().checkReviews(request, false);
@ -645,7 +674,140 @@ public class NewPullRequestPage extends ProjectPage implements RevisionAnnotatio
}
private Fragment newCanSendFrag() {
Fragment fragment = new Fragment("status", "canSendFrag", this);
var behavior = new AbstractPostAjaxBehavior() {
@Override
protected void respond(AjaxRequestTarget target) {
var params = RequestCycle.get().getRequest().getPostParameters();
var suggestTitle = params.getParameterValue("suggestTitle").toBoolean();
var suggestDescription = params.getParameterValue("suggestDescription").toBoolean();
String title;
String description;
try {
if (suggestTitle || suggestDescription) {
var chatModel = settingService.getAISetting().getLiteModel();
var sourceBranchSemantic = getPullRequest().getSourceBranchSemantic();
String titleSuggestInstruction;
if (sourceBranchSemantic.isWorkInProgress()) {
if (sourceBranchSemantic.getWorkType() != null) {
titleSuggestInstruction = """
When suggesting pull request title, you should not add work in progress prefix
or conventional commit type prefix to the title even if commit messages
indicate that.
""";
} else {
titleSuggestInstruction = """
When suggesting pull request title, you should not add work in progress prefix
to the title even if commit messages indicate that.
""";
}
} else {
if (sourceBranchSemantic.getWorkType() != null) {
titleSuggestInstruction = """
When suggesting pull request title, you should not add conventional commit type prefix
to the title even if commit messages indicate that.
""";
} else {
titleSuggestInstruction = "";
}
}
var userPrompt = getPullRequest().getLatestUpdate().getCommits().stream()
.map(it -> it.getFullMessage())
.collect(Collectors.toList());
var userMessage = new UserMessage("A json array of commit messages:\n" + objectMapper.writeValueAsString(userPrompt));
if (!suggestTitle) {
var systemMessage = new SystemMessage(String.format("""
You are a helpful assistant that can suggest pull request description by
summarizing multiple commit messages. Maximum %d characters allowed for
the description.
IMPORTANT: only return the description, no other text or comments.
""", MAX_DESCRIPTION_LEN));
description = chatModel.chat(systemMessage, userMessage).aiMessage().text();
title = "";
} else if (!suggestDescription) {
var systemMessage = new SystemMessage(String.format("""
You are a helpful assistant that can suggest pull request title by summarizing
multiple commit messages. %s Maximum %d characters allowed for the title.
IMPORTANT: only return the title, no other text or comments.
""", titleSuggestInstruction, MAX_TITLE_LEN));
title = (getPullRequest().getTitlePrefix(sourceBranchSemantic) + chatModel.chat(systemMessage, userMessage).aiMessage().text()).trim();
description = "";
} else {
var systemMessage = new SystemMessage(String.format("""
You are a helpful assistant that can suggest pull request title and description
by summarizing multiple commit messages. %s Maximum %d characters allowed for the title,
and %d characters allowed for the description.
IMPORTANT: only return a VALID json object with "title" property set to the title and "description"
property set to the description, no other text or comments.
""", titleSuggestInstruction, MAX_TITLE_LEN, MAX_DESCRIPTION_LEN));
var responseText = chatModel.chat(systemMessage, userMessage).aiMessage().text();
if (responseText.startsWith("```json"))
responseText = responseText.substring("```json".length());
if (responseText.endsWith("```"))
responseText = responseText.substring(0, responseText.length() - "```".length());
var response = objectMapper.readTree(responseText);
if (response.has("title")) {
title = (getPullRequest().getTitlePrefix(sourceBranchSemantic) + response.get("title").asText()).trim();
} else {
title = "";
}
if (response.has("description"))
description = response.get("description").asText();
else
description = "";
}
} else {
title = "";
description = "";
}
} catch (Exception e) {
title = "";
description = "";
var explicitException = ExceptionUtils.find(e, ExplicitException.class);
if (explicitException != null) {
Session.get().error(explicitException.getMessage());
} else {
logger.error("Error suggesting title/description", e);
Session.get().error("Error suggesting title/description, check server log for details");
}
}
var script = String.format("onedev.server.newPullRequest.titleAndDescriptionSuggested('%s', '%s');",
escapeJavaScript(title), escapeJavaScript(description));
target.appendJavaScript(script);
}
};
Fragment fragment = new Fragment("status", "canSendFrag", this) {
@Override
public void renderHead(IHeaderResponse response) {
super.renderHead(response);
CharSequence callback;
if (settingService.getAISetting().getLiteModelSetting() != null)
callback = behavior.getCallbackFunction(explicit("suggestTitle"), explicit("suggestDescription"));
else
callback = "undefined";
var translations = new HashMap<String, String>();
translations.put("suggesting-title", _T("Suggesting title..."));
translations.put("suggesting-description", _T("Suggesting description..."));
try {
var script = String.format("onedev.server.newPullRequest.onCanSendLoad(%s, %s);",
callback, objectMapper.writeValueAsString(translations));
response.render(OnLoadHeaderItem.forScript(script));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
};
fragment.add(behavior);
Form<?> form = new Form<Void>("form");
fragment.add(form);
@ -763,7 +925,7 @@ public class NewPullRequestPage extends ProjectPage implements RevisionAnnotatio
};
descriptionInput.add(validatable -> {
if (validatable.getValue().length() > PullRequest.MAX_DESCRIPTION_LEN) {
if (validatable.getValue().length() > MAX_DESCRIPTION_LEN) {
validatable.error(messageSource -> _T("Description too long"));
}
});
@ -943,7 +1105,7 @@ public class NewPullRequestPage extends ProjectPage implements RevisionAnnotatio
@Override
public void renderHead(IHeaderResponse response) {
super.renderHead(response);
response.render(CssHeaderItem.forReference(new NewPullRequestCssResourceReference()));
response.render(JavaScriptHeaderItem.forReference(new NewPullRequestResourceReference()));
}
@Override

View File

@ -7,18 +7,21 @@ import org.apache.wicket.markup.head.HeaderItem;
import io.onedev.server.web.asset.revisioncompare.RevisionCompareCssResourceReference;
import io.onedev.server.web.page.base.BaseDependentCssResourceReference;
import io.onedev.server.web.page.base.BaseDependentResourceReference;
public class NewPullRequestCssResourceReference extends BaseDependentCssResourceReference {
public class NewPullRequestResourceReference extends BaseDependentResourceReference {
private static final long serialVersionUID = 1L;
public NewPullRequestCssResourceReference() {
super(NewPullRequestCssResourceReference.class, "new-pull-request.css");
public NewPullRequestResourceReference() {
super(NewPullRequestResourceReference.class, "new-pull-request.js");
}
@Override
public List<HeaderItem> getDependencies() {
List<HeaderItem> dependencies = super.getDependencies();
dependencies.add(CssHeaderItem.forReference(new BaseDependentCssResourceReference(
NewPullRequestResourceReference.class, "new-pull-request.css")));
dependencies.add(CssHeaderItem.forReference(new RevisionCompareCssResourceReference()));
return dependencies;
}

View File

@ -0,0 +1,83 @@
onedev.server.newPullRequest = {
onCanSendLoad: function(titleAndDescriptionSuggestionCallback, translations) {
// Do below logic in onLoad as unsaved description will be set in onLoad event of markdown editor,
// and we do not want to suggest description if unsaved description is loaded
if (titleAndDescriptionSuggestionCallback) {
var icon = onedev.server.isDarkMode()? "sparkle.gif": "sparkle-dark.gif";
var indicatorHtml = "<div class='ajax-loading-indicator suggesting-indicator'><img src='/~img/" + icon + "' width='16' height='16'></div>";
var $title = $(".pull-request-title");
var $titleInput = $title.find("input");
if ($titleInput.val().length == 0) {
$title.css("position", "relative");
$titleInput.prop("readonly", true);
$titleInput.data("placeholder", $titleInput.attr("placeholder"));
$titleInput.removeAttr("placeholder");
var $indicator = $(indicatorHtml);
$indicator.append("<span class='text-muted'>" + translations["suggesting-title"] + "</span>");
var titleCoord = $title.offset();
var titleInputCoord = $titleInput.offset();
var left = titleInputCoord.left - titleCoord.left + 5;
var top = titleInputCoord.top - titleCoord.top + 10;
$indicator.css({
position: "absolute",
left: left + "px",
top: top + "px",
zIndex: 1000
});
$title.append($indicator);
}
var $description = $(".pull-request-description");
var $descriptionInput = $description.find("textarea");
if ($descriptionInput.val().length == 0) {
$description.css("position", "relative");
$descriptionInput.prop("readonly", true);
$indicator = $(indicatorHtml);
$indicator.append("<span class='text-muted'>" + translations["suggesting-description"] + "</span>");
var descriptionCoord = $description.offset();
var descriptionInputCoord = $descriptionInput.offset();
var left = descriptionInputCoord.left - descriptionCoord.left + 5;
var top = descriptionInputCoord.top - descriptionCoord.top + 10;
$indicator.css({
position: "absolute",
left: left + "px",
top: top + "px",
zIndex: 1000
});
$description.append($indicator);
}
titleAndDescriptionSuggestionCallback($titleInput.val().length == 0, $descriptionInput.val().length == 0);
}
},
titleAndDescriptionSuggested: function(title, description) {
var $title = $(".pull-request-title");
var $titleInput = $title.find("input");
$titleInput.prop("readonly", false);
$title.children(".suggesting-indicator").remove();
var placeholder = $titleInput.data("placeholder");
if (placeholder)
$titleInput.attr("placeholder", placeholder);
if (title)
$titleInput.val(title);
var $description = $(".pull-request-description");
var $descriptionInput = $description.find("textarea");
$descriptionInput.prop("readonly", false);
$description.children(".suggesting-indicator").remove();
if (description) {
$descriptionInput.val(description);
$descriptionInput.trigger("input");
}
}
}