Repository: iterative/cml
Pull request #1228 (2022-10-15 05:41:19)

Feature comment target
This is a WIP branch

- [x] #1202
- [x] #1241
- [x] test for no breaking changes
- [x] open documentation follow-up https://github.com/iterative/cml.dev/pull/415

karl-richter: Hi @tasdomas, do I understand it correct that this feature would allow to comment (and update comments) on a pull request given the respective pr number on the repo? I was about to open an issue as I seem to have run into a deadlock with commenting on a pr in the target repo when opening the pr from a branch in a fork. Seems like this could resolve the issue.
tasdomas: > Hi @tasdomas, do I understand it correct that this feature would allow to comment (and update comments) on a pull request given the respective pr number on the repo? I was about to open an issue as I seem to have run into a deadlock with commenting on a pr in the target repo when working with forks. Seems like this could resolve the issue.

Yes, this will be one of the features this will introduce
karl-richter: > > Hi @tasdomas, do I understand it correct that this feature would allow to comment (and update comments) on a pull request given the respective pr number on the repo? I was about to open an issue as I seem to have run into a deadlock with commenting on a pr in the target repo when working with forks. Seems like this could resolve the issue.
> 
> Yes, this will be one of the features this will introduce

Sounds awesome! Do you have an eta for the pr / whats your release cycle?
Let me know if I can support with getting this shipped!
DavidGOrtega: @tasdomas what happened with this effort?
tasdomas: > @tasdomas what happened with this effort?
This is a WIP at the moment, this branch is a feature branch focused on introducing this change.

Once we've landed #1241 and done all the necessary testing, this will be ready for release.
github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=c575b32e-4a6b-4306-b34b-bb611f23dbff)

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=bb5e2bc0-a140-4abe-9433-219251c8c199)

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=924543a5-91fb-4a84-8a49-6ad782c2d469)

![](https://cml.dev/watermark.png "CML watermark")

casperdcl: > One of the upsides of this change is that we no longer need `ref: ${{ github.event.pull_request.head.sha }}` to be able to correctly match comments to commits.

so does this address #1204 too?
tasdomas: > > One of the upsides of this change is that we no longer need `ref: ${{ github.event.pull_request.head.sha }}` to be able to correctly match comments to commits.
> 
> so does this address #1204 too?

`cml check create` seems to resolve the commit sha correctly, so yes, this does address #1204 too
github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=ca294bc5-c0f3-4990-917a-70daa2b52bab)

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=fe0a5842-fcf1-4f0e-aeb7-d6a7c635e276)

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=89dcb8eb-b073-482e-b80f-a6071bf1a316)

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=6274d949-b6bc-400d-a23e-2cd9e921a665)

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=b1608746-2dbf-4dfc-b442-b12ca25c0cd3)

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=465a434f-f188-40d4-b091-3a3acbc15719)

![](https://cml.dev/watermark.png "CML watermark")

casperdcl: Also qq - does this work as expected?

```yaml
on: [issue_comment, pull_request_review_comment]
...
run: cml comment create
```
tasdomas: > Also qq - does this work as expected?
> 
> ```yaml
> on: [issue_comment, pull_request_review_comment]
> ...
> run: cml comment create
> ```
What is the expected behavior at this point?

Should `cml` automatically create issue comments when running in a `issue_comment` context? It currently does not do that.

The second trigger (`pull_request_review_comment`)  will be handled correctly - the created comment will be a pull request comment.


github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=85f73acb-a76b-4dc2-abfc-10ebdbd58587)

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

casperdcl: ok [this](https://github.com/iterative/cml/pull/1228#issuecomment-1342815784) isn't a blocker for me - happy to move to a follow-up, but yes I'd expect `on: [issue_comment]` to create comments in the issue by default.
github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=10ebc874-4c4f-44ad-a5ea-646fe8369787)

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=07204ccf-33b3-4e22-8eac-58bb3e26832d)

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=26a262f2-8863-45ea-a34b-41ca98402853)

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=e2c22254-64a5-4beb-8826-447be8af23bc)

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=71e6bc86-2cee-4f13-8314-5480c0e4f424)

![](https://cml.dev/watermark.png "CML watermark")

casperdcl: Are there any blockers for this? Too scary for anyone to click "merge"?
tasdomas: And I can't merge this - need an approval
github-actions[bot]: ## Test Comment

![](https://cml.dev/watermark.png "CML watermark")

github-actions[bot]: ## Test Comment

![](https://asset.cml.dev/65c49ca187aaddca0d979dab472f1d0a077b2bd0?cml=png&cache-bypass=b4c1ec87-668b-4835-9e35-230b45b244ba)

![](https://cml.dev/watermark.png "CML watermark")


Patch:
diff --git a/README.md b/README.md
index 8fdb0b8..574b053 100644
--- a/README.md
+++ b/README.md
@@ -637,6 +637,7 @@ These are some example projects using CML.
 - [CML with Tensorboard](https://github.com/iterative-test/cml-example-tensorboard)
 - [CML with a small EC2 instance](https://github.com/iterative-test/cml-example-cloud)
   :key:
-- [CML with EC2 GPU](https://github.com/iterative-test/cml-example-cloud-gpu) :key:
+- [CML with EC2 GPU](https://github.com/iterative-test/cml-example-cloud-gpu)
+  :key:
 
 :key: needs a [PAT](#environment-variables).
diff --git a/bin/cml/comment/create.js b/bin/cml/comment/create.js
index eda8df3..918b3b4 100644
--- a/bin/cml/comment/create.js
+++ b/bin/cml/comment/create.js
@@ -18,16 +18,25 @@ exports.builder = (yargs) =>
     .options(exports.options);
 
 exports.options = kebabcaseKeys({
+  target: {
+    type: 'string',
+    description:
+      'Comment type (`commit`, `pr`, `commit/f00bar`, `pr/42`, `issue/1337`),' +
+      'default is automatic (`pr` but fallback to `commit`).'
+  },
   pr: {
     type: 'boolean',
     description:
-      'Post to an existing PR/MR associated with the specified commit'
+      'Post to an existing PR/MR associated with the specified commit',
+    conflicts: ['target', 'commitSha'],
+    hidden: true
   },
   commitSha: {
     type: 'string',
     alias: 'head-sha',
-    default: 'HEAD',
-    description: 'Commit SHA linked to this comment'
+    description: 'Commit SHA linked to this comment',
+    conflicts: ['target', 'pr'],
+    hidden: true
   },
   watch: {
     type: 'boolean',
diff --git a/src/cml.js b/src/cml.js
index d88bfec..541cc6b 100755
--- a/src/cml.js
+++ b/src/cml.js
@@ -10,6 +10,7 @@ const winston = require('winston');
 const remark = require('remark');
 const visit = require('unist-util-visit');
 
+const { parseCommentTarget } = require('./commenttarget');
 const Gitlab = require('./drivers/gitlab');
 const Github = require('./drivers/github');
 const BitbucketCloud = require('./drivers/bitbucket_cloud');
@@ -162,15 +163,15 @@ class CML {
   }
 
   async commentCreate(opts = {}) {
-    const triggerSha = await this.triggerSha();
     const {
-      commitSha: inCommitSha = triggerSha,
+      commitSha,
       markdownFile,
       pr,
       publish,
       publishUrl,
       report: testReport,
       rmWatermark,
+      target: commentTarget = 'auto',
       triggerFile,
       update,
       watch,
@@ -179,9 +180,6 @@ class CML {
 
     const drv = this.getDriver();
 
-    const commitSha =
-      (await this.revParse({ ref: inCommitSha })) || inCommitSha;
-
     if (rmWatermark && update)
       throw new Error('watermarks are mandatory for updateable comments');
 
@@ -300,58 +298,26 @@ class CML {
       });
     };
 
-    const isBB = this.driver === BB;
-    if (pr || isBB) {
-      let commentUrl;
-
-      if (commitSha !== triggerSha)
-        winston.info(
-          `Looking for PR associated with --commit-sha="${inCommitSha}".\nSee https://cml.dev/doc/ref/send-comment.`
-        );
-
-      const longReport = `${commitSha.substr(0, 7)}\n\n${report}`;
-      const [commitPr = {}] = await drv.commitPrs({ commitSha });
-      const { url } = commitPr;
-
-      if (!url && !isBB)
-        throw new Error(`PR for commit sha "${inCommitSha}" not found`);
-
-      if (url) {
-        const [prNumber] = url.split('/').slice(-1);
-
-        if (update)
-          comment = updatableComment(await drv.prComments({ prNumber }));
-
-        if (update && comment) {
-          commentUrl = await drv.prCommentUpdate({
-            report: longReport,
-            id: comment.id,
-            prNumber
-          });
-        } else
-          commentUrl = await drv.prCommentCreate({
-            report: longReport,
-            prNumber
-          });
-
-        if (this.driver !== 'bitbucket') return commentUrl;
-      }
-    }
+    const target = await parseCommentTarget({
+      commitSha,
+      pr,
+      target: commentTarget,
+      drv
+    });
 
     if (update) {
-      comment = updatableComment(await drv.commitComments({ commitSha }));
+      comment = updatableComment(await drv[target.target + 'Comments'](target));
 
       if (comment)
-        return await drv.commentUpdate({
+        return await drv[target.target + 'CommentUpdate']({
           report,
           id: comment.id,
-          commitSha
+          ...target
         });
     }
-
-    return await drv.commentCreate({
+    return await drv[target.target + 'CommentCreate']({
       report,
-      commitSha
+      ...target
     });
   }
 
diff --git a/src/commenttarget.js b/src/commenttarget.js
new file mode 100644
index 0000000..fd52721
--- /dev/null
+++ b/src/commenttarget.js
@@ -0,0 +1,75 @@
+const winston = require('winston');
+
+const SEPARATOR = '/';
+
+async function parseCommentTarget(opts = {}) {
+  const { commitSha: commit, pr, target, drv } = opts;
+
+  let commentTarget = target;
+  // Handle legacy comment target flags.
+  if (commit) {
+    drv.warn(
+      `Deprecation warning: use --target="commit${SEPARATOR}<sha>" instead of --commit-sha=<sha>`
+    );
+    commentTarget = `commit${SEPARATOR}${commit}`;
+  }
+  if (pr) {
+    drv.warn('Deprecation warning: use --target=pr instead of --pr');
+    commentTarget = 'pr';
+  }
+  // Handle comment targets that are incomplete, e.g. 'pr' or 'commit'.
+  let prNumber;
+  let commitPr;
+  switch (commentTarget.toLowerCase()) {
+    case 'commit':
+      winston.debug(`Comment target "commit" mapped to "commit/${drv.sha}"`);
+      return { target: 'commit', commitSha: drv.sha };
+    case 'pr':
+    case 'auto':
+      // Determine PR id from forge env vars (if we're in a PR context).
+      prNumber = drv.pr;
+      if (prNumber) {
+        winston.debug(
+          `Comment target "${commentTarget}" mapped to "pr/${prNumber}"`
+        );
+        return { target: 'pr', prNumber: prNumber };
+      }
+      // Or fallback to determining PR by HEAD commit.
+      // TODO: handle issue with PR HEAD commit not matching source branch in github.
+      [commitPr = {}] = await drv.commitPrs({ commitSha: drv.sha });
+      if (commitPr.url) {
+        [prNumber] = commitPr.url.split('/').slice(-1);
+        winston.debug(
+          `Comment target "${commentTarget}" mapped to "pr/${prNumber}" based on commit "${drv.sha}"`
+        );
+        return { target: 'pr', prNumber };
+      }
+      // If target is 'auto', fallback to issuing commit comments.
+      if (commentTarget === 'auto') {
+        winston.debug(
+          `Comment target "${commentTarget}" mapped to "commit/${drv.sha}"`
+        );
+        return { target: 'commit', commitSha: drv.sha };
+      }
+      throw new Error(`PR for commit sha "${drv.sha}" not found`);
+  }
+  // Handle qualified comment targets, e.g. 'issue/id'.
+  const separatorPos = commentTarget.indexOf(SEPARATOR);
+  if (separatorPos === -1) {
+    throw new Error(`Failed to parse comment --target="${commentTarget}"`);
+  }
+  const targetType = commentTarget.slice(0, separatorPos);
+  const id = commentTarget.slice(separatorPos + 1);
+  switch (targetType.toLowerCase()) {
+    case 'commit':
+      return { target: targetType, commitSha: id };
+    case 'pr':
+      return { target: targetType, prNumber: id };
+    case 'issue':
+      return { target: targetType, issueId: id };
+    default:
+      throw new Error(`unsupported comment target "${commentTarget}"`);
+  }
+}
+
+module.exports = { parseCommentTarget };
diff --git a/src/drivers/bitbucket_cloud.js b/src/drivers/bitbucket_cloud.js
index d5a3dbc..4c56986 100644
--- a/src/drivers/bitbucket_cloud.js
+++ b/src/drivers/bitbucket_cloud.js
@@ -33,7 +33,45 @@ class BitbucketCloud {
     }
   }
 
-  async commentCreate(opts = {}) {
+  async issueCommentUpsert(opts = {}) {
+    const { projectPath } = this;
+    const { issueId, report, id } = opts;
+
+    const endpoint =
+      `/repositories/${projectPath}/issues/${issueId}/` +
+      `comments/${id ? id + '/' : ''}`;
+    return (
+      await this.request({
+        endpoint,
+        method: id ? 'PUT' : 'POST',
+        body: JSON.stringify({ content: { raw: report } })
+      })
+    ).links.html.href;
+  }
+
+  async issueCommentCreate(opts = {}) {
+    const { id, ...rest } = opts;
+    return this.issueCommentUpsert(rest);
+  }
+
+  async issueCommentUpdate(opts = {}) {
+    if (!opts.id) throw new Error('Id is missing updating comment');
+    return this.issueCommentUpsert(opts);
+  }
+
+  async issueComments(opts = {}) {
+    const { projectPath } = this;
+    const { issueId } = opts;
+
+    const endpoint = `/repositories/${projectPath}/issues/${issueId}/comments/`;
+    return (await this.paginatedRequest({ endpoint, method: 'GET' })).map(
+      ({ id, content: { raw: body = '' } = {} }) => {
+        return { id, body };
+      }
+    );
+  }
+
+  async commitCommentCreate(opts = {}) {
     const { projectPath } = this;
     const { commitSha, report } = opts;
 
@@ -47,7 +85,7 @@ class BitbucketCloud {
     ).links.html.href;
   }
 
-  async commentUpdate(opts = {}) {
+  async commitCommentUpdate(opts = {}) {
     const { projectPath } = this;
     const { commitSha, report, id } = opts;
 
@@ -431,10 +469,6 @@ class BitbucketCloud {
     return commands;
   }
 
-  warn(message) {
-    winston.warn(message);
-  }
-
   get workflowId() {
     return BITBUCKET_PIPELINE_UUID;
   }
@@ -447,6 +481,16 @@ class BitbucketCloud {
     return BITBUCKET_COMMIT;
   }
 
+  /**
+   * Returns the PR number if we're in a PR-related action event.
+   */
+  get pr() {
+    if ('BITBUCKET_PR_ID' in process.env) {
+      return process.env.BITBUCKET_PR_ID;
+    }
+    return null;
+  }
+
   get branch() {
     return BITBUCKET_BRANCH;
   }
@@ -512,6 +556,10 @@ class BitbucketCloud {
 
     return responseBody;
   }
+
+  warn(message) {
+    winston.warn(message);
+  }
 }
 
 module.exports = BitbucketCloud;
diff --git a/src/drivers/github.js b/src/drivers/github.js
index ce064f7..ac21ad3 100644
--- a/src/drivers/github.js
+++ b/src/drivers/github.js
@@ -101,7 +101,7 @@ class Github {
     return user;
   }
 
-  async commentCreate(opts = {}) {
+  async commitCommentCreate(opts = {}) {
     const { report: body, commitSha } = opts;
     const { repos } = octokit(this.token, this.repo);
 
@@ -114,7 +114,7 @@ class Github {
     ).data.html_url;
   }
 
-  async commentUpdate(opts = {}) {
+  async commitCommentUpdate(opts = {}) {
     const { report: body, id } = opts;
     const { repos } = octokit(this.token, this.repo);
 
@@ -549,6 +549,57 @@ class Github {
     }
   }
 
+  async issueCommentCreate(opts = {}) {
+    const { issueId, report } = opts;
+    const { owner, repo } = ownerRepo({ uri: this.repo });
+    const { issues } = octokit(this.token, this.repo);
+
+    const {
+      data: { html_url: htmlUrl }
+    } = await issues.createComment({
+      owner,
+      repo,
+      body: report,
+      issue_number: issueId
+    });
+
+    return htmlUrl;
+  }
+
+  async issueCommentUpdate(opts = {}) {
+    const { id, report } = opts;
+    if (!id) throw new Error('Id is missing updating comment');
+    const { owner, repo } = ownerRepo({ uri: this.repo });
+    const { issues } = octokit(this.token, this.repo);
+
+    const {
+      data: { html_url: htmlUrl }
+    } = await issues.updateComment({
+      owner,
+      repo,
+      body: report,
+      comment_id: id
+    });
+
+    return htmlUrl;
+  }
+
+  async issueComments(opts = {}) {
+    const { issueId } = opts;
+    const { owner, repo } = ownerRepo({ uri: this.repo });
+    const { issues } = octokit(this.token, this.repo);
+
+    const { data: comments } = await issues.listComments({
+      owner,
+      repo,
+      issue_number: issueId
+    });
+
+    return comments.map(({ id, body }) => {
+      return { id, body };
+    });
+  }
+
   async prCommentCreate(opts = {}) {
     const { report: body, prNumber } = opts;
     const { owner, repo } = ownerRepo({ uri: this.repo });
@@ -735,6 +786,16 @@ class Github {
     return GITHUB_SHA;
   }
 
+  /**
+   * Returns the PR number if we're in a PR-related action event.
+   */
+  get pr() {
+    if (['pull_request', 'pull_request_target'].includes(GITHUB_EVENT_NAME)) {
+      return github.context.payload.pull_request.number;
+    }
+    return null;
+  }
+
   get branch() {
     return branchName(GITHUB_HEAD_REF || GITHUB_REF);
   }
diff --git a/src/drivers/gitlab.js b/src/drivers/gitlab.js
index 9159471..4b57acc 100644
--- a/src/drivers/gitlab.js
+++ b/src/drivers/gitlab.js
@@ -66,7 +66,7 @@ class Gitlab {
     return this.detectedBase;
   }
 
-  async commentCreate(opts = {}) {
+  async commitCommentCreate(opts = {}) {
     const { commitSha, report } = opts;
 
     const projectPath = await this.projectPath();
@@ -79,7 +79,7 @@ class Gitlab {
     return `${this.repo}/-/commit/${commitSha}`;
   }
 
-  async commentUpdate(opts = {}) {
+  async commitCommentUpdate(opts = {}) {
     throw new Error('GitLab does not support comment updates!');
   }
 
@@ -330,6 +330,51 @@ class Gitlab {
     }
   }
 
+  async issueCommentUpsert(opts = {}) {
+    const projectPath = await this.projectPath();
+    const { issueId, report, id: commentId } = opts;
+
+    const endpoint =
+      `/projects/${projectPath}/issues/${issueId}/notes` +
+      `${commentId ? '/' + commentId : ''}`;
+    const body = new URLSearchParams();
+    body.append('body', report);
+
+    const { id } = await this.request({
+      endpoint,
+      method: commentId ? 'PUT' : 'POST',
+      body
+    });
+
+    return `${this.repo}/-/issues/${issueId}#note_${id}`;
+  }
+
+  async issueCommentCreate(opts = {}) {
+    const { id, ...rest } = opts;
+    return this.issueCommentUpsert(rest);
+  }
+
+  async issueCommentUpdate(opts = {}) {
+    if (!opts.id) throw new Error('Id is missing updating comment');
+    return this.issueCommentUpsert(opts);
+  }
+
+  async issueComments(opts = {}) {
+    const projectPath = await this.projectPath();
+    const { issueId } = opts;
+
+    const endpoint = `/projects/${projectPath}/issues/${issueId}/notes`;
+
+    const comments = await this.request({
+      endpoint,
+      method: 'GET'
+    });
+
+    return comments.map(({ id, body }) => {
+      return { id, body };
+    });
+  }
+
   async prCommentCreate(opts = {}) {
     const projectPath = await this.projectPath();
     const { report, prNumber } = opts;
@@ -450,10 +495,6 @@ class Gitlab {
     return commands;
   }
 
-  warn(message) {
-    winston.warn(message);
-  }
-
   get workflowId() {
     return CI_PIPELINE_ID;
   }
@@ -466,6 +507,16 @@ class Gitlab {
     return process.env.CI_COMMIT_SHA;
   }
 
+  /**
+   * Returns the PR number if we're in a PR-related action event.
+   */
+  get pr() {
+    if ('CI_MERGE_REQUEST_IID' in process.env) {
+      return process.env.CI_MERGE_REQUEST_IID;
+    }
+    return null;
+  }
+
   get branch() {
     return process.env.CI_BUILD_REF_NAME;
   }
@@ -505,6 +556,10 @@ class Gitlab {
 
     return await response.json();
   }
+
+  warn(message) {
+    winston.warn(message);
+  }
 }
 
 module.exports = Gitlab;


Test patch:
diff --git a/.github/workflows/test-deploy.yml b/.github/workflows/test-deploy.yml
index 3f30059..3cd4ab5 100644
--- a/.github/workflows/test-deploy.yml
+++ b/.github/workflows/test-deploy.yml
@@ -58,12 +58,15 @@ jobs:
           TEST_GITHUB_TOKEN: ${{ secrets.TEST_GITHUB_TOKEN }}
           TEST_GITHUB_REPO: https://github.com/iterative/cml_qa_tests_dummy
           TEST_GITHUB_SHA: 0cd16da26e35f8e5d57b2549a97e22618abf08f6
+          TEST_GITHUB_ISSUE: 1
           TEST_GITLAB_TOKEN: ${{ secrets.TEST_GITLAB_TOKEN }}
           TEST_GITLAB_REPO: https://gitlab.com/iterative.ai/cml_qa_tests_dummy
           TEST_GITLAB_SHA: f8b8b49a253243830ef59a7f090eb887157b2b67
+          TEST_GITLAB_ISSUE: 1
           TEST_BBCLOUD_TOKEN: ${{ secrets.TEST_BBCLOUD_TOKEN }}
           TEST_BBCLOUD_REPO: https://bitbucket.org/iterative-ai/cml-qa-tests-dummy
           TEST_BBCLOUD_SHA: b511535a89f76d3d311b1c15e3e712b15c0b94e3
+          TEST_BBCLOUD_ISSUE: 1
   test-os:
     needs: authorize
     name: test-${{ matrix.system }}
diff --git a/bin/cml/comment/create.test.js b/bin/cml/comment/create.test.js
index b52d221..e01676f 100644
--- a/bin/cml/comment/create.test.js
+++ b/bin/cml/comment/create.test.js
@@ -17,10 +17,9 @@ describe('Comment integration tests', () => {
         --help    Show help                                                  [boolean]
 
       Options:
-        --pr                        Post to an existing PR/MR associated with the
-                                    specified commit                         [boolean]
-        --commit-sha, --head-sha    Commit SHA linked to this comment
-                                                            [string] [default: \\"HEAD\\"]
+        --target                    Comment type (\`commit\`, \`pr\`, \`commit/f00bar\`,
+                                    \`pr/42\`, \`issue/1337\`),default is automatic (\`pr\`
+                                    but fallback to \`commit\`).                [string]
         --watch                     Watch for changes and automatically update the
                                     comment                                  [boolean]
         --publish                   Upload any local images found in the Markdown
diff --git a/src/commenttarget.test.js b/src/commenttarget.test.js
new file mode 100644
index 0000000..7a41af8
--- /dev/null
+++ b/src/commenttarget.test.js
@@ -0,0 +1,173 @@
+const { parseCommentTarget } = require('../src/commenttarget');
+
+describe('comment target tests', () => {
+  beforeEach(() => {
+    jest.resetModules();
+  });
+
+  test('qualified comment target: pr', async () => {
+    const target = await parseCommentTarget({
+      target: 'pr/3'
+    });
+    expect(target).toEqual({ target: 'pr', prNumber: '3' });
+  });
+
+  test('qualified comment target: commit', async () => {
+    const target = await parseCommentTarget({
+      target: 'commit/abcdefg'
+    });
+    expect(target).toEqual({ target: 'commit', commitSha: 'abcdefg' });
+  });
+
+  test('qualified comment target: issue', async () => {
+    const target = await parseCommentTarget({
+      target: 'issue/3'
+    });
+    expect(target).toEqual({ target: 'issue', issueId: '3' });
+  });
+
+  test('qualified comment target: unsupported', async () => {
+    try {
+      await parseCommentTarget({
+        target: 'unsupported/3'
+      });
+    } catch (error) {
+      expect(error.message).toBe('unsupported comment target "unsupported/3"');
+    }
+  });
+
+  test('legacy flag: commitSha', async () => {
+    const drv = { warn: () => {} };
+    const target = await parseCommentTarget({
+      drv,
+      commitSha: 'abcdefg',
+      target: 'issue/3' // target will be replaced with commit
+    });
+    expect(target).toEqual({ target: 'commit', commitSha: 'abcdefg' });
+  });
+
+  // Test retrieving the PR id from the driver's context.
+  test('legacy flag: pr, context', async () => {
+    const drv = {
+      warn: () => {},
+      pr: '4' // driver returns the PR id from context
+    };
+    const target = await parseCommentTarget({
+      drv,
+      pr: true,
+      target: 'issue/3' // target will be replaced
+    });
+    expect(target).toEqual({ target: 'pr', prNumber: '4' });
+  });
+
+  // Test calculating the PR id based on the commit sha.
+  test('legacy flag: pr, commit sha', async () => {
+    const drv = {
+      warn: () => {},
+      pr: null, // not in PR context
+      commitPrs: () => [{ url: 'forge/pr/4' }],
+      sha: 'abcdefg'
+    };
+    const target = await parseCommentTarget({
+      drv,
+      pr: true,
+      target: 'issue/3' // target will be replaced
+    });
+    expect(target).toEqual({ target: 'pr', prNumber: '4' });
+  });
+
+  // Test using driver supplied commit sha.
+  test('unqualified flag: commit sha', async () => {
+    const drv = {
+      warn: () => {},
+      sha: 'abcdefg'
+    };
+    const target = await parseCommentTarget({
+      drv,
+      target: 'commit'
+    });
+    expect(target).toEqual({ target: 'commit', commitSha: 'abcdefg' });
+  });
+
+  // Test retrieving the PR id from the driver's context.
+  test('unqualified flag: pr, context', async () => {
+    const drv = {
+      warn: () => {},
+      pr: '4' // driver returns the PR id from context
+    };
+    const target = await parseCommentTarget({
+      drv,
+      target: 'pr' // target will be replaced
+    });
+    expect(target).toEqual({ target: 'pr', prNumber: '4' });
+  });
+
+  // Test calculating the PR id based on the commit sha.
+  test('unqualified flag: pr, commit sha', async () => {
+    const drv = {
+      warn: () => {},
+      pr: null, // not in PR context
+      commitPrs: () => [{ url: 'forge/pr/4' }],
+      sha: 'abcdefg'
+    };
+    const target = await parseCommentTarget({
+      drv,
+      pr: true,
+      target: 'pr' // target will be replaced
+    });
+    expect(target).toEqual({ target: 'pr', prNumber: '4' });
+  });
+
+  test('unqualified comment target: issue', async () => {
+    try {
+      await parseCommentTarget({
+        target: 'issue'
+      });
+    } catch (error) {
+      expect(error.message).toBe('Failed to parse comment --target="issue"');
+    }
+  });
+
+  test('auto comment target: pr context', async () => {
+    const drv = {
+      warn: () => {},
+      pr: '4' // driver returns the PR id from context
+    };
+
+    const target = await parseCommentTarget({
+      drv,
+      target: 'auto'
+    });
+    expect(target).toEqual({ target: 'pr', prNumber: '4' });
+  });
+
+  test('auto comment target: pr, commit sha', async () => {
+    const drv = {
+      warn: () => {},
+      pr: null, // not in PR context
+      commitPrs: () => [{ url: 'forge/pr/5' }],
+      sha: 'abcdefg'
+    };
+
+    const target = await parseCommentTarget({
+      drv,
+      target: 'auto'
+    });
+    expect(target).toEqual({ target: 'pr', prNumber: '5' });
+  });
+
+  test('auto comment target: fallback commit sha', async () => {
+    const drv = {
+      warn: () => {},
+      pr: null, // not in PR context
+      commitPrs: () => [],
+      sha: 'abcdefg'
+    };
+
+    const target = await parseCommentTarget({
+      drv,
+      target: 'auto'
+    });
+    expect(target).toEqual({ target: 'commit', commitSha: 'abcdefg' });
+  });
+});
diff --git a/src/drivers/bitbucket_cloud.e2e.test.js b/src/drivers/bitbucket_cloud.e2e.test.js
index fb2d94b..7715b59 100644
--- a/src/drivers/bitbucket_cloud.e2e.test.js
+++ b/src/drivers/bitbucket_cloud.e2e.test.js
@@ -2,7 +2,8 @@ const BitbucketCloud = require('./bitbucket_cloud');
 const {
   TEST_BBCLOUD_TOKEN: TOKEN,
   TEST_BBCLOUD_REPO: REPO,
-  TEST_BBCLOUD_SHA: SHA
+  TEST_BBCLOUD_SHA: SHA,
+  TEST_BBCLOUD_ISSUE: ISSUE = 1
 } = process.env;
 
 describe('Non Enviromental tests', () => {
@@ -13,12 +14,20 @@ describe('Non Enviromental tests', () => {
     expect(client.token).toBe(TOKEN);
   });
 
+  test('Issue comment', async () => {
+    const report = '## Test comment';
+    const issueId = ISSUE;
+    const url = await client.issueCommentCreate({ report, issueId });
+
+    expect(url.startsWith('https://')).toBe(true);
+  });
+
   test('Comment', async () => {
     const report = '## Test comment';
     const commitSha = SHA;
-    const url = await client.commentCreate({ report, commitSha });
+    const url = await client.commitCommentCreate({ report, commitSha });
 
-    expect(url.startsWith('https://')).toBe(true);
+    expect(url.startsWith(REPO)).toBe(true);
   });
 
   test('Check', async () => {
diff --git a/src/drivers/github.e2e.test.js b/src/drivers/github.e2e.test.js
index b85eb2d..e2cc9ee 100644
--- a/src/drivers/github.e2e.test.js
+++ b/src/drivers/github.e2e.test.js
@@ -3,7 +3,8 @@ const GithubClient = require('./github');
 const {
   TEST_GITHUB_TOKEN: TOKEN,
   TEST_GITHUB_REPO: REPO,
-  TEST_GITHUB_SHA: SHA
+  TEST_GITHUB_SHA: SHA,
+  TEST_GITHUB_ISSUE: ISSUE = 1
 } = process.env;
 
 describe('Non Enviromental tests', () => {
@@ -19,12 +20,20 @@ describe('Non Enviromental tests', () => {
     expect(repo).toBe(parts[parts.length - 1]);
   });
 
+  test('Issue comment', async () => {
+    const report = '## Test comment';
+    const issueId = ISSUE;
+    const url = await client.issueCommentCreate({ issueId, report });
+
+    expect(url.startsWith(REPO)).toBe(true);
+  });
+
   test('Comment', async () => {
     const report = '## Test comment';
     const commitSha = SHA;
-    const url = await client.commentCreate({ report, commitSha });
+    const url = await client.commitCommentCreate({ report, commitSha });
 
-    expect(url.startsWith('https://')).toBe(true);
+    expect(url.startsWith(REPO)).toBe(true);
   });
 
   test('Publish', async () => {
diff --git a/src/drivers/gitlab.e2e.test.js b/src/drivers/gitlab.e2e.test.js
index b06fb21..c3212b6 100644
--- a/src/drivers/gitlab.e2e.test.js
+++ b/src/drivers/gitlab.e2e.test.js
@@ -3,7 +3,8 @@ const GitlabClient = require('./gitlab');
 const {
   TEST_GITLAB_TOKEN: TOKEN,
   TEST_GITLAB_REPO: REPO,
-  TEST_GITLAB_SHA: SHA
+  TEST_GITLAB_SHA: SHA,
+  TEST_GITLAB_ISSUE: ISSUE = 1
 } = process.env;
 
 describe('Non Enviromental tests', () => {
@@ -14,10 +15,18 @@ describe('Non Enviromental tests', () => {
     expect(client.token).toBe(TOKEN);
   });
 
+  test('Issue comment', async () => {
+    const report = '## Test comment';
+    const issueId = ISSUE;
+    const url = await client.issueCommentCreate({ issueId, report });
+
+    expect(url.startsWith(REPO)).toBe(true);
+  });
+
   test('Comment', async () => {
     const report = '## Test comment';
     const commitSha = SHA;
-    const url = await client.commentCreate({
+    const url = await client.commitCommentCreate({
       report,
       commitSha
     });